feat: move web endpoint into a first-party plugin

This commit is contained in:
LukeGus committed 2026-09-22 12:30:35 -05:00
1 parent 6f0dff58a3
commit 6e058f6e9e
26 files changed
+496 -121

No files matched your search

+71
View File
@@ -0,0 +1,71 @@
# Web Endpoint
Opens a host's web UI from Termix, either directly or through an SSH tunnel, in an
embedded tab or an isolated desktop window.
Each host can define up to 16 endpoints (`MAX_WEB_ENDPOINTS`). An endpoint is either:
- **direct** — the browser loads `scheme://host:port/path` itself, or
- **tunnel** — the backend opens a local SSH forward to the target port and the frame
loads it over loopback.
## Layout
| Path | What it is |
| ------------------------------------- | ---------------------------------------------------------------------- |
| `backend/index.mjs` | Plugin entry: `activate` starts the service, `deactivate` stops it. |
| `backend/routes.ts` | `POST /open` — opens or reuses the forward and returns the bound port. |
| `frontend/WebEndpointTab.tsx` | The embedded viewer (a `credentialless` sandboxed iframe). |
| `frontend/web-endpoint-api.ts` | Client for the open route plus the Electron bridges. |
| `frontend/web-endpoint-url.ts` | URL building and the cookie/reachability refusal rules. |
| `frontend/web-endpoint-validation.ts` | Editor-side mirror of the backend normalizer. |
## What stays in core
This plugin owns its lifecycle, not all of its code. Four pieces deliberately stay in
Termix proper:
- **The tunnel manager** (`src/backend/hosts/tunnel/manager.ts`). It serves the whole
server-tunnels feature. The reserved `web:` tunnel-name prefix that exempts these
tunnels from the retry machinery is part of its own disconnect path.
- **`host-web-endpoints.ts`** (`src/backend/database/routes/`). Despite sitting in
`routes/` it has no router — it is the `webUiConfig` normalizer, imported by `host.ts`,
`host-bulk-routes.ts` and `host-normalizers.ts` to sanitize the column on every host
save and list. Disabling this plugin must not stop that validation, and core cannot
import plugin code in any case.
- **The Electron half** (`electron/web-endpoint-window.cjs` plus the
`open-isolated-web-endpoint` and `allow-invalid-certificate-for-origin` handlers in
`main.cjs`). `BrowserWindow` and `session` are main-process-only, the renderer is what
invokes those channels, and the main process loads no plugin code today.
- **`HostEditorWebUiSection.tsx`** (`src/ui/sidebar/`). It is a section inside the host
editor's General tab and takes the core-owned `{ form, setField }` pair, the same
reason docker leaves `HostDockerTab` in core.
The `enableWebUi` and `webUiConfig` columns stay on core's `ssh_data` table, declared
here through `contributes.hostCapability`.
## Why it runs in-process
The open route polls the tunnel manager's live maps, builds a `TunnelConfig` from
plaintext host credentials that `ctx.hosts` deliberately withholds, and hands the
resulting live `ssh2.Client` to `forwardOut`. A structured-clone postMessage boundary
can carry none of those, so this is a genuine transport dependency — see
`src/backend/plugins/first-party.ts`.
## Routing
The route is served by the tunnel service on port 30003, so the dispatcher lives at
`src/backend/hosts/tunnel/web-endpoint-dispatch.ts` rather than in `database.ts` with
the others. The public path stays `/ssh/tunnel/web-endpoint/open`, which the generic
`location /ssh/tunnel/` block in both nginx configs already proxies — so no nginx change
was needed.
## Tests
Backend tests are in `src/backend/tests/plugins/web-endpoint/` (the backend vitest
project only globs `src/backend/**`). Frontend tests sit beside the source here, which
the frontend project picks up via `plugins/**/frontend/**/*.test.{ts,tsx}`.
`web-endpoint-validation.test.ts` deliberately imports the backend normalizer as well as
the editor one and runs both over the same samples, so the two implementations cannot
drift.
+75
View File
@@ -0,0 +1,75 @@
/**
* Web Endpoint - first-party, in-process plugin.
*
* This plugin runs on the main thread because its one route is a real
* transport consumer: it polls the tunnel manager's live maps
* (activeTunnelRuntimes, connectionStatus, tunnelConnecting), needs the
* plaintext host credentials ctx.hosts deliberately withholds (PluginHostView
* is a 13-field allowlist excluding password, key and passphrase), and hands a
* live ssh2.Client to forwardOut to probe the target. None of that survives
* the structured-clone postMessage boundary a worker plugin talks over.
*
* Unlike every other dispatcher-backed plugin, this one does not register with
* a dispatcher in database.ts: its route is served by the tunnel service on
* port 30003, so the dispatcher lives at
* src/backend/hosts/tunnel/web-endpoint-dispatch.ts instead. Both that service
* and the plugin runtime load into the same backend process, so the
* dispatcher's module-level slot is shared between them. Keeping the URL
* (/ssh/tunnel/web-endpoint/open) byte-identical is also why no nginx change
* was needed -- the generic /ssh/tunnel/ location block already covers it.
*
* Three pieces stay in core rather than moving here:
*
* - The tunnel manager itself (src/backend/hosts/tunnel/manager.ts). It
* serves the whole server-tunnels feature, and the reserved "web:" name
* prefix that exempts these tunnels from the retry machinery is part of
* its own disconnect path.
*
* - host-web-endpoints.ts (the webUiConfig normalizer). Despite its name it
* has no router: it is imported by host.ts, host-bulk-routes.ts and
* host-normalizers.ts to sanitize the column on every host save and list.
* Disabling this plugin must not stop that validation, and core cannot
* import plugin code in any case.
*
* - The Electron half (electron/web-endpoint-window.cjs and the two
* ipcMain handlers in main.cjs). BrowserWindow and session are
* main-process-only, the renderer is what invokes those channels, and the
* main process loads no plugin code at all today.
*
* The enableWebUi/webUiConfig columns stay on core's ssh_data table, declared
* here through contributes.hostCapability -- this plugin system has no
* owned-table migration mechanism of its own.
*/
import path from "node:path";
import { fileURLToPath } from "node:url";
/**
* Loads a module by its path relative to this file's own directory.
*
* The plugin ships beside dist/backend, so in a built server the compiled .js
* for routes.js sits right next to this .mjs. Under vitest/dev nothing imports
* this plugin directly (the loader loads in-process plugins with a plain
* dynamic import, no tsx loader involved), so the only layout that needs to
* resolve here is the built one.
*/
async function loadRelative(relativePath) {
const here = path.dirname(fileURLToPath(import.meta.url));
const target = path.join(here, `${relativePath}.js`);
return import(new URL(`file://${target.replace(/\\/g, "/")}`).href);
}
let routesModule = null;
export async function activate(ctx) {
routesModule = await loadRelative("routes");
routesModule.startWebEndpointService();
ctx.log.info("Web Endpoint router registered at /ssh/tunnel/web-endpoint");
}
export async function deactivate() {
if (routesModule) {
routesModule.stopWebEndpointService();
routesModule = null;
}
}
+323
View File
@@ -0,0 +1,323 @@
import express from "express";
import { Client } from "ssh2";
import { AuthManager } from "../../../src/backend/utils/auth-manager.js";
import { tunnelLogger } from "../../../src/backend/utils/logger.js";
import { parseWebUiConfig } from "../../../src/backend/database/routes/host-web-endpoints.js";
import {
activeTunnelRuntimes,
cleanupTunnelResources,
connectSSHTunnel,
connectionStatus,
tunnelConnecting,
} from "../../../src/backend/hosts/tunnel/manager.js";
import { forwardOut } from "../../../src/backend/hosts/tunnel/ssh-primitives.js";
import { buildWebEndpointTunnelName } from "../../../src/backend/hosts/tunnel/utils.js";
import {
registerWebEndpointRouter,
unregisterWebEndpointRouter,
} from "../../../src/backend/hosts/tunnel/web-endpoint-dispatch.js";
import type { TunnelConfig, WebEndpoint } from "../../../src/types/index.js";
/** Matches the spec's ten minutes. */
const WEB_ENDPOINT_IDLE_TIMEOUT_MS = 10 * 60 * 1000;
/**
* Long enough for a slow SSH handshake, short enough that a wedged connection
* does not hold the HTTP request open indefinitely. One second past the
* manager's own connection timeout so the manager reports first.
*/
const TUNNEL_READY_TIMEOUT_MS = 61_000;
const TUNNEL_READY_POLL_MS = 100;
const TARGET_PROBE_TIMEOUT_MS = 10_000;
/**
* What the live forward was built for. Compared on reuse so an endpoint edited
* under a live tunnel is reopened rather than silently forwarding to the old
* target.
*
* JSON rather than a joined string: no separator can be smuggled in by a field
* value, and the result stays readable in a log. The SSH identity is included
* because a host repointed at a different server must also invalidate the
* forward, not just a changed endpoint port.
*/
function targetFingerprint(
endpoint: Pick<
WebEndpoint,
"scheme" | "port" | "access" | "bindHost" | "localPort"
>,
host: { ip?: string; port?: number; username?: string },
): string {
return JSON.stringify({
scheme: endpoint.scheme,
port: endpoint.port,
access: endpoint.access,
bindHost: endpoint.bindHost ?? null,
localPort: endpoint.localPort ?? null,
ip: host.ip ?? null,
sshPort: host.port ?? null,
username: host.username ?? null,
});
}
const openedTargets = new Map<string, string>();
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* connectSSHTunnel(config) returns Promise<void> and NEVER REJECTS: every
* failure path inside it reports over broadcastTunnelStatus and returns
* normally, and its own promise resolves right after conn.connect(), long
* before the SSH "ready" event that would populate activeTunnelRuntimes.
*
* So "awaited" is not "connected". This is what actually waits.
*
* Polling the maps the manager already exports, rather than adding a
* promise-returning variant to manager.ts: every existing caller of
* connectSSHTunnel is fire-and-forget by design (the connect route responds
* "Connection request received" immediately and reports asynchronously over
* the status broadcast), so changing that contract to serve one new caller is
* a far larger blast radius than polling here.
*/
async function waitForTunnelSettled(tunnelName: string): Promise<void> {
const deadline = Date.now() + TUNNEL_READY_TIMEOUT_MS;
while (Date.now() < deadline) {
if (activeTunnelRuntimes.has(tunnelName)) return;
const status = connectionStatus.get(tunnelName);
if (!tunnelConnecting.has(tunnelName) && status && !status.connected) {
throw new Error(status.reason || "The endpoint tunnel failed to connect");
}
await sleep(TUNNEL_READY_POLL_MS);
}
throw new Error("Timed out establishing the endpoint tunnel");
}
/**
* Opens one channel to the target and closes it immediately.
*
* Without this the route returns 200 the moment tcpServer.listen succeeds, and
* forwardOut is only attempted per inbound socket -- where a failure is
* swallowed by the socket's own error handling. The user then gets a blank
* frame and no message for the single likeliest error: nothing is actually
* listening on that port. Costs one round trip per open.
*/
async function probeEndpointTarget(
sourceClient: Client,
targetPort: number,
tunnelName: string,
): Promise<void> {
const channel = await Promise.race([
forwardOut(sourceClient, "127.0.0.1", targetPort, tunnelName),
new Promise<never>((_resolve, reject) =>
setTimeout(
() => reject(new Error("Timed out reaching the endpoint port")),
TARGET_PROBE_TIMEOUT_MS,
),
),
]);
try {
channel.end();
} catch {
// Already gone; the probe succeeded either way.
}
}
export async function handleWebEndpointOpen(
req: express.Request,
res: express.Response,
): Promise<express.Response | void> {
const userId = (req as { userId?: string }).userId;
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, exactly as the server tunnels feature does, and the
// endpoint's own bindHost decides whether that is reachable from the
// browser -- loopback for the desktop, an address the server answers on for
// a web deployment.
const { resolveHostById } =
await import("../../../src/backend/hosts/host-resolver.js");
const host = await resolveHostById(hostId, userId);
if (!host) {
return res.status(403).json({ error: "Host not found or access denied" });
}
// All three write paths null the config on disable, so a configured-but-
// disabled host is not normally reachable -- except a bulk update that sends
// only { webUiConfig } while the stored enable_web_ui is still 0. The UI
// reads as off everywhere in that state, so the endpoint merely being
// present in the config is not on its own a licence to open a tunnel.
if (!host.enableWebUi) {
return res
.status(400)
.json({ error: "Web endpoints are not enabled for this host" });
}
// Re-normalized rather than trusted: the stored value predates any later
// tightening of the rules, and this is the value a forward is built from.
const endpoint = parseWebUiConfig(host.webUiConfig).endpoints.find(
(candidate) => candidate.id === endpointId,
);
if (!endpoint) {
return res.status(400).json({ error: "Web endpoint not found" });
}
if (endpoint.access !== "tunnel") {
return res
.status(400)
.json({ error: "This endpoint does not use a tunnel" });
}
const tunnelName = buildWebEndpointTunnelName(hostId, endpointId);
const fingerprint = targetFingerprint(endpoint, host);
const existing = activeTunnelRuntimes.get(tunnelName);
if (existing) {
const recorded = openedTargets.get(tunnelName);
// The runtime is the source of truth and openedTargets is derived from it,
// so an ABSENT fingerprint means reuse, not staleness. A reserved-prefixed
// runtime can only have been created by this route.
if (recorded === undefined || recorded === fingerprint) {
openedTargets.set(tunnelName, fingerprint);
return res.status(200).json({ port: existing.bindPort });
}
tunnelLogger.info("Reopening web endpoint tunnel after config change", {
operation: "web_endpoint_reopen",
tunnelName,
});
// Forced, for the same reason handleDisconnect's reserved-name branch
// forces: cleanupTunnelResources no-ops while tunnelConnecting holds the
// name, and a web tunnel has no retry pass to self-heal a runtime left
// behind. An unforced call could leave a dead listener registered.
await cleanupTunnelResources(tunnelName, true);
openedTargets.delete(tunnelName);
}
const tunnelConfig: TunnelConfig = {
name: tunnelName,
scope: "s2s",
mode: "local",
tunnelType: "local",
// Loopback unless the endpoint asks otherwise. A non-loopback bind
// publishes the target's web UI to anyone who can reach the port, with no
// authentication in front of it -- the same trade the server tunnels
// feature exposes, and only ever an explicit per-endpoint choice. The
// value is validated to a bare host literal by normalizeBindHost.
bindHost: endpoint.bindHost || "127.0.0.1",
sourceHostId: host.id ?? hostId,
tunnelIndex: 0,
requestingUserId: userId,
hostName: host.name || `${host.username}@${host.ip}`,
sourceIP: host.ip,
sourceSSHPort: host.port,
sourceUsername: host.username,
sourcePassword: host.password,
sourceAuthMethod: host.authType,
sourceSSHKey: host.key,
sourceKeyPassword: host.keyPassword,
sourceKeyType: host.keyType,
sourceCredentialId: host.credentialId,
// connectSSHTunnel reads sourceUserId when building its client.
sourceUserId: host.userId,
endpointIP: host.ip,
endpointSSHPort: host.port,
endpointUsername: host.username,
// Load-bearing: connectSSHTunnel picks its strategy via
// shouldEstablishDirectTunnel -> isSingleHostTunnel, which returns true
// when endpointHost is "127.0.0.1". That is what selects
// establishDirectTunnel's local branch -- the bind-locally-and-forwardOut
// path this feature needs. Setting only targetHost would NOT select it.
endpointHost: "127.0.0.1",
endpointAuthMethod: host.authType,
endpointSSHKey: host.key,
endpointKeyPassword: host.keyPassword,
endpointKeyType: host.keyType,
endpointCredentialId: host.credentialId,
endpointUserId: host.userId,
// A fixed port when the endpoint names one, as the server tunnels
// feature's Source Port does -- a container can only publish ports it
// knows in advance. Otherwise 0 and the kernel picks; the manager reports
// back what it actually bound either way.
sourcePort: endpoint.localPort ?? 0,
endpointPort: endpoint.port,
// Documentation only: maxRetries 0 does NOT disable retries, because the
// manager reads `maxRetries || 3`. Retry is actually suppressed by the
// reserved-name early return in handleDisconnect.
maxRetries: 0,
retryInterval: 0,
autoStart: false,
isPinned: Boolean(host.pin),
idleTimeoutMs: WEB_ENDPOINT_IDLE_TIMEOUT_MS,
useSocks5: Boolean(host.useSocks5),
socks5Host: host.socks5Host,
socks5Port: host.socks5Port,
socks5Username: host.socks5Username,
socks5Password: host.socks5Password,
socks5ProxyChain: host.socks5ProxyChain,
} as TunnelConfig;
try {
await connectSSHTunnel(tunnelConfig);
await waitForTunnelSettled(tunnelName);
const runtime = activeTunnelRuntimes.get(tunnelName);
if (!runtime) {
throw new Error("The endpoint tunnel closed before it could be used");
}
await probeEndpointTarget(runtime.sourceClient, endpoint.port, tunnelName);
openedTargets.set(tunnelName, fingerprint);
return res.status(200).json({ port: runtime.bindPort });
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
tunnelLogger.error("Failed to open web endpoint tunnel", error, {
operation: "web_endpoint_open_failed",
tunnelName,
});
// Leave nothing half-built: a listener with no working forward behind it
// would be handed out on the next open as a live tunnel.
await cleanupTunnelResources(tunnelName, true).catch(() => undefined);
openedTargets.delete(tunnelName);
return res.status(502).json({ error: reason });
}
}
export const router = express.Router();
let started = false;
export function startWebEndpointService(): void {
if (!started) {
// Built on first start rather than at module scope so importing this
// module for a handler unit test does not construct the auth singleton.
const authenticateJWT = AuthManager.getInstance().createAuthMiddleware();
// Only "/open" here: the dispatcher this router is registered with is
// mounted at /ssh/tunnel/web-endpoint, so the full path stays
// /ssh/tunnel/web-endpoint/open exactly as before. The "/ssh" prefix is
// part of the path the tunnel service serves -- nginx proxies /ssh/tunnel/
// through with the path intact.
router.post("/open", authenticateJWT, handleWebEndpointOpen);
started = true;
}
registerWebEndpointRouter(router);
}
export function stopWebEndpointService(): void {
unregisterWebEndpointRouter();
}
@@ -0,0 +1,433 @@
import "@testing-library/jest-dom/vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import type { WebEndpoint } from "@/types/index";
import type { Host } from "@/types/ui-types";
const openWebEndpointTunnel = vi.hoisted(() => vi.fn());
const allowInvalidCertificateForOrigin = vi.hoisted(() => vi.fn());
vi.mock("./web-endpoint-api", async (importOriginal) => {
// requireNumericHostId is left as the real implementation -- the "no saved
// host" case exercises the actual validation, not a re-implementation of it
// that could drift.
const actual = await importOriginal<typeof import("./web-endpoint-api")>();
return {
...actual,
openWebEndpointTunnel: (...args: unknown[]) =>
openWebEndpointTunnel(...args),
allowInvalidCertificateForOrigin: (...args: unknown[]) =>
allowInvalidCertificateForOrigin(...args),
};
});
const electron = vi.hoisted(() => ({ isElectron: vi.fn(() => true) }));
vi.mock("@/lib/electron", () => electron);
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
function endpoint(overrides: Partial<WebEndpoint> = {}): WebEndpoint {
return {
id: "e1",
label: "Proxmox",
scheme: "https",
port: 8006,
path: "/",
access: "direct",
render: "embedded",
...overrides,
};
}
function host(
overrides: Partial<Host> = {},
endpoints: WebEndpoint[] = [],
): Host {
return {
id: "7",
ip: "192.168.1.10",
webUiConfig: { endpoints },
...overrides,
} as unknown as Host;
}
let pageHostname = "localhost";
const realLocation = window.location;
beforeEach(() => {
Object.defineProperty(HTMLIFrameElement.prototype, "credentialless", {
configurable: true,
writable: true,
value: false,
});
pageHostname = "localhost";
electron.isElectron.mockReturnValue(true);
Object.defineProperty(window, "location", {
configurable: true,
get: () => ({ ...realLocation, hostname: pageHostname }),
});
});
afterEach(() => {
cleanup();
delete (HTMLIFrameElement.prototype as { credentialless?: boolean })
.credentialless;
Object.defineProperty(window, "location", {
configurable: true,
value: realLocation,
});
openWebEndpointTunnel.mockReset();
allowInvalidCertificateForOrigin.mockReset();
});
describe("WebEndpointTab", () => {
it("refuses unsupported environments before creating a tunnel or iframe", async () => {
delete (HTMLIFrameElement.prototype as { credentialless?: boolean })
.credentialless;
const { WebEndpointTab } = await import("./WebEndpointTab");
render(
<WebEndpointTab
host={host({}, [endpoint({ access: "tunnel" })])}
endpointId="e1"
/>,
);
await screen.findByText("webEndpoint.isolationUnavailable");
expect(openWebEndpointTunnel).not.toHaveBeenCalled();
expect(document.querySelector("iframe")).toBeNull();
});
it("isolates cookie state and denies parent access, popups and top navigation", async () => {
const { WebEndpointTab } = await import("./WebEndpointTab");
render(<WebEndpointTab host={host({}, [endpoint()])} endpointId="e1" />);
const frame = (await screen.findByTitle("Proxmox")) as HTMLIFrameElement;
expect(
(frame as HTMLIFrameElement & { credentialless: boolean }).credentialless,
).toBe(true);
expect(frame.getAttribute("sandbox")).toBe("allow-scripts allow-forms");
expect(frame.getAttribute("referrerpolicy")).toBe("no-referrer");
expect(screen.queryByText("webEndpoint.openExternally")).toBeNull();
});
it("renders an iframe at the direct URL", async () => {
const { WebEndpointTab } = await import("./WebEndpointTab");
render(<WebEndpointTab host={host({}, [endpoint()])} endpointId="e1" />);
await waitFor(() =>
expect(screen.getByTitle("Proxmox")).toHaveAttribute(
"src",
"https://192.168.1.10:8006/",
),
);
});
it("opens the tunnel through the numeric host id and frames the loopback URL", async () => {
openWebEndpointTunnel.mockResolvedValue(41234);
const { WebEndpointTab } = await import("./WebEndpointTab");
render(
<WebEndpointTab
host={host({}, [endpoint({ access: "tunnel" })])}
endpointId="e1"
/>,
);
await waitFor(() => {
expect(openWebEndpointTunnel).toHaveBeenCalledWith(7, "e1");
expect(screen.getByTitle("Proxmox")).toHaveAttribute(
"src",
"https://127.0.0.1:41234/",
);
});
});
it("registers the certificate allowance for a direct endpoint with ignoreCert", async () => {
const { WebEndpointTab } = await import("./WebEndpointTab");
render(
<WebEndpointTab
host={host({}, [endpoint({ ignoreCert: true })])}
endpointId="e1"
/>,
);
await waitFor(() =>
expect(allowInvalidCertificateForOrigin).toHaveBeenCalledWith(
"https://192.168.1.10:8006",
),
);
});
it("reframes at the new port when a reopened tunnel binds a different one", async () => {
openWebEndpointTunnel
.mockResolvedValueOnce(41234)
.mockResolvedValueOnce(51234);
const { WebEndpointTab } = await import("./WebEndpointTab");
render(
<WebEndpointTab
host={host({}, [endpoint({ access: "tunnel" })])}
endpointId="e1"
/>,
);
await waitFor(() =>
expect(screen.getByTitle("Proxmox")).toHaveAttribute(
"src",
"https://127.0.0.1:41234/",
),
);
fireEvent.click(screen.getByTitle("webEndpoint.reload"));
await waitFor(() =>
expect(screen.getByTitle("Proxmox")).toHaveAttribute(
"src",
"https://127.0.0.1:51234/",
),
);
expect(openWebEndpointTunnel).toHaveBeenCalledTimes(2);
});
/**
* The case the port-changed test above CANNOT exercise, and the one that
* matters most: a live tunnel returns the SAME port on every open, and a
* direct endpoint's URL never changes at all. If reload just did
* setUrl(resolved), React would bail out of the same-value setState and the
* frame would never remount -- so the button would do nothing in the two
* most common cases. Capturing the DOM node before and after is what catches
* that: a same-value setState never touches the DOM.
*/
it("remounts the frame on reload even when the resolved URL is unchanged", async () => {
openWebEndpointTunnel.mockResolvedValue(41234);
const { WebEndpointTab } = await import("./WebEndpointTab");
render(
<WebEndpointTab
host={host({}, [endpoint({ access: "tunnel" })])}
endpointId="e1"
/>,
);
const firstFrame = await screen.findByTitle("Proxmox");
expect(firstFrame).toHaveAttribute("src", "https://127.0.0.1:41234/");
fireEvent.click(screen.getByTitle("webEndpoint.reload"));
await waitFor(() => expect(openWebEndpointTunnel).toHaveBeenCalledTimes(2));
// Polled rather than asserted once: against the defective implementation
// this never becomes true and the test times out -- a real failure, not a
// race.
await waitFor(() => {
expect(screen.getByTitle("Proxmox")).not.toBe(firstFrame);
});
});
it("shows the failure reason when the tunnel cannot open", async () => {
openWebEndpointTunnel.mockRejectedValue(
new Error("Timed out reaching the endpoint port"),
);
const { WebEndpointTab } = await import("./WebEndpointTab");
render(
<WebEndpointTab
host={host({}, [endpoint({ access: "tunnel" })])}
endpointId="e1"
/>,
);
await waitFor(() =>
expect(
screen.getByText(/Timed out reaching the endpoint port/),
).toBeInTheDocument(),
);
});
it("shows a plain message when the endpoint id matches nothing on the host", async () => {
const { WebEndpointTab } = await import("./WebEndpointTab");
render(<WebEndpointTab host={host({}, [])} endpointId="deleted" />);
await waitFor(() =>
expect(screen.getByText("webEndpoint.notFound")).toBeInTheDocument(),
);
expect(openWebEndpointTunnel).not.toHaveBeenCalled();
expect(document.querySelector("iframe")).toBeNull();
});
it("explains that a tunnel needs a saved host, instead of calling the route", async () => {
const { WebEndpointTab } = await import("./WebEndpointTab");
render(
<WebEndpointTab
host={host({ id: "quick-connect-1" }, [endpoint({ access: "tunnel" })])}
endpointId="e1"
/>,
);
await waitFor(() =>
expect(screen.getByText(/requires a saved host/)).toBeInTheDocument(),
);
expect(openWebEndpointTunnel).not.toHaveBeenCalled();
});
it("resolves a direct endpoint normally on the same quick-connect host", async () => {
// The positive case: requireNumericHostId must be consulted only on the
// tunnel branch, or direct endpoints would break on unsaved hosts too.
const { WebEndpointTab } = await import("./WebEndpointTab");
render(
<WebEndpointTab
host={host({ id: "quick-connect-1" }, [endpoint({ access: "direct" })])}
endpointId="e1"
/>,
);
await waitFor(() =>
expect(screen.getByTitle("Proxmox")).toHaveAttribute(
"src",
"https://192.168.1.10:8006/",
),
);
expect(openWebEndpointTunnel).not.toHaveBeenCalled();
});
it("frames a tunnel at the OTHER loopback spelling in a web deployment", async () => {
// jsdom serves the page at "localhost". The forward binds where the
// backend runs, so the browser must reach it on this same machine -- but
// NOT at the same host string, or the tunnelled service receives Termix's
// session cookie.
electron.isElectron.mockReturnValue(false);
openWebEndpointTunnel.mockResolvedValue(41234);
const { WebEndpointTab } = await import("./WebEndpointTab");
render(
<WebEndpointTab
host={host({}, [endpoint({ access: "tunnel", bindHost: "0.0.0.0" })])}
endpointId="e1"
/>,
);
await waitFor(() =>
expect(screen.getByTitle("Proxmox")).toHaveAttribute(
"src",
"https://127.0.0.1:41234/",
),
);
// The assertion carrying the security property: whatever host the frame
// lands on, it must not be the one serving Termix.
const framed = new URL(
screen.getByTitle("Proxmox").getAttribute("src") as string,
).hostname;
expect(framed).not.toBe(window.location.hostname);
});
it("refuses a loopback-bound tunnel in a browser instead of framing a dead port", async () => {
electron.isElectron.mockReturnValue(false);
const { WebEndpointTab } = await import("./WebEndpointTab");
render(
<WebEndpointTab
host={host({}, [endpoint({ access: "tunnel" })])}
endpointId="e1"
/>,
);
await waitFor(() =>
expect(
screen.getByText("webEndpoint.tunnelUnreachableFromBrowser"),
).toBeInTheDocument(),
);
expect(openWebEndpointTunnel).not.toHaveBeenCalled();
expect(document.querySelector("iframe")).toBeNull();
});
it("refuses when the page host has no separate loopback spelling", async () => {
electron.isElectron.mockReturnValue(false);
pageHostname = "termix.example.com";
const { WebEndpointTab } = await import("./WebEndpointTab");
render(
<WebEndpointTab
host={host({}, [endpoint({ access: "tunnel", bindHost: "0.0.0.0" })])}
endpointId="e1"
/>,
);
await waitFor(() =>
expect(
screen.getByText("webEndpoint.tunnelSharesSessionCookie"),
).toBeInTheDocument(),
);
// Refusing after opening would still have bound the port and still have
// leaked on the first frame load.
expect(openWebEndpointTunnel).not.toHaveBeenCalled();
expect(document.querySelector("iframe")).toBeNull();
});
/**
* The leak the tunnel guard did not cover: a DIRECT endpoint points at the
* host's own address, so when that host is the one serving Termix the frame
* is same-site with Termix and the browser attaches the `jwt` cookie. A
* different port is not a different cookie key.
*/
it("refuses a direct endpoint on the same host that serves Termix", async () => {
electron.isElectron.mockReturnValue(false);
pageHostname = "termix.example";
const { WebEndpointTab } = await import("./WebEndpointTab");
render(
<WebEndpointTab
host={host({ ip: "termix.example" }, [
endpoint({ access: "direct", port: 8443 }),
])}
endpointId="e1"
/>,
);
await waitFor(() =>
expect(
screen.getByText("webEndpoint.directSharesSessionCookie"),
).toBeInTheDocument(),
);
// Refusing after framing would have leaked on the very first request.
expect(document.querySelector("iframe")).toBeNull();
});
it("refuses a direct endpoint on a sub domain of the page host", async () => {
electron.isElectron.mockReturnValue(false);
pageHostname = "termix.example.com";
const { WebEndpointTab } = await import("./WebEndpointTab");
render(
<WebEndpointTab
host={host({ ip: "ui.termix.example.com" }, [
endpoint({ access: "direct" }),
])}
endpointId="e1"
/>,
);
await waitFor(() =>
expect(
screen.getByText("webEndpoint.directSharesSessionCookie"),
).toBeInTheDocument(),
);
expect(document.querySelector("iframe")).toBeNull();
});
it("still frames a direct same-host endpoint on the desktop, whose jar has no jwt", async () => {
electron.isElectron.mockReturnValue(true);
pageHostname = "termix.example";
const { WebEndpointTab } = await import("./WebEndpointTab");
render(
<WebEndpointTab
host={host({ ip: "termix.example" }, [
endpoint({ access: "direct", port: 8443 }),
])}
endpointId="e1"
/>,
);
await waitFor(() =>
expect(screen.getByTitle("Proxmox")).toHaveAttribute(
"src",
"https://termix.example:8443/",
),
);
});
it("still frames a DIRECT endpoint on a hostname that refuses tunnels", async () => {
electron.isElectron.mockReturnValue(false);
pageHostname = "termix.example.com";
const { WebEndpointTab } = await import("./WebEndpointTab");
render(<WebEndpointTab host={host({}, [endpoint()])} endpointId="e1" />);
await waitFor(() =>
expect(screen.getByTitle("Proxmox")).toHaveAttribute(
"src",
"https://192.168.1.10:8006/",
),
);
});
});
@@ -0,0 +1,227 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Globe, RotateCw } from "lucide-react";
import { toast } from "sonner";
import {
currentTunnelHost,
resolveWebEndpointUrl,
webEndpointRefusalReason,
type WebEndpointRefusalReason,
} from "./web-endpoint-url";
import {
allowInvalidCertificateForOrigin,
openWebEndpointExternally,
openWebEndpointTunnel,
requireNumericHostId,
} from "./web-endpoint-api";
import { copyToClipboard } from "@/lib/clipboard";
import { isElectron } from "@/lib/electron";
import { Button } from "@/components/button";
import type { Host } from "@/types/ui-types";
const REFUSAL_MESSAGES: Record<WebEndpointRefusalReason, string> = {
"loopback-bind-on-remote-backend": "webEndpoint.tunnelUnreachableFromBrowser",
"shares-session-cookie-with-termix": "webEndpoint.tunnelSharesSessionCookie",
"direct-shares-session-cookie": "webEndpoint.directSharesSessionCookie",
};
/**
* Renders one web endpoint in an iframe.
*
* Takes the whole host plus the endpoint's ID -- not the endpoint object --
* so a tab whose endpoint was deleted while it was open (or that never matched
* one, if the host's webUiConfig raced the tab's mount) can be detected here
* and shown a plain "no longer exists" message instead of throwing on
* endpoint.scheme.
*/
export function WebEndpointTab({
host,
endpointId,
}: {
host: Host;
endpointId?: string;
}) {
const { t } = useTranslation();
const endpoint = (host.webUiConfig?.endpoints ?? []).find(
(candidate) => candidate.id === endpointId,
);
const [url, setUrl] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
/**
* Bumped on every resolution that lands, and folded into the iframe's key
* alongside the URL. A direct endpoint's URL never changes, and a live
* tunnel returns the SAME port on every open -- so the URL alone is not
* enough to key on: React bails out of a same-value setState, the key would
* not change, and Reload would silently do nothing in the two most common
* cases.
*/
const [generation, setGeneration] = useState(0);
/**
* Guards two in-flight resolutions completing out of order -- a
* double-clicked Reload, or a manual reload racing the mount effect. Only
* the most recently started resolution may apply its result.
*/
const resolveGenerationRef = useRef(0);
const resolve = useCallback(async () => {
if (!endpoint) return;
const mine = ++resolveGenerationRef.current;
setError(null);
try {
// Checked BEFORE the open call, not after. A tunnel must not be opened
// from a browser when a loopback bind on a remote backend would leave
// nothing to connect to, or when the tunnel URL would land on the page's
// own host string. A DIRECT endpoint on the same host that serves Termix
// leaks the session cookie exactly the same way -- cookies ignore the
// port -- so it is refused here too, before any navigation, rather than
// framing the URL and leaking on the first request.
const refusal = webEndpointRefusalReason(endpoint, isElectron(), host.ip);
if (refusal) {
throw new Error(t(REFUSAL_MESSAGES[refusal]));
}
if (!("credentialless" in HTMLIFrameElement.prototype)) {
throw new Error(t("webEndpoint.isolationUnavailable"));
}
// Always re-resolved rather than reloading the frame: the backend closes
// an idle tunnel after ten minutes and re-binds a fresh kernel-assigned
// port, so a cached port may be gone.
const localPort =
endpoint.access === "tunnel"
? await openWebEndpointTunnel(
requireNumericHostId(host.id),
endpoint.id,
)
: undefined;
const resolved = resolveWebEndpointUrl({
hostAddress: host.ip,
endpoint,
localPort,
// Non-null for a tunnel by the time we get here: a null separated host
// is exactly what the refusal above catches.
tunnelHost: currentTunnelHost(isElectron()) ?? undefined,
});
if (endpoint.access === "direct" && endpoint.ignoreCert) {
await allowInvalidCertificateForOrigin(new URL(resolved).origin);
}
if (mine !== resolveGenerationRef.current) return;
setUrl(resolved);
setGeneration((g) => g + 1);
} catch (caught) {
if (mine !== resolveGenerationRef.current) return;
setError(caught instanceof Error ? caught.message : String(caught));
}
// Depends on the endpoint's FIELDS, not the object: host.webUiConfig is
// re-derived on every render of the host list, so a dependency on
// `endpoint` itself would reopen the tunnel on every parent render.
// bindHost and localPort are included because the refusal guard reads them.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
host.id,
host.ip,
endpoint?.id,
endpoint?.access,
endpoint?.scheme,
endpoint?.port,
endpoint?.path,
endpoint?.ignoreCert,
endpoint?.bindHost,
endpoint?.localPort,
]);
useEffect(() => {
void resolve();
}, [resolve]);
if (!endpoint) {
return (
<div className="flex h-full flex-col items-center justify-center gap-2 p-6 text-xs text-muted-foreground">
<Globe className="size-5 opacity-60" />
<p>{t("webEndpoint.notFound")}</p>
</div>
);
}
if (error) {
return (
<div className="flex h-full flex-col items-center justify-center gap-2 p-6 text-xs">
<Globe className="size-5 opacity-60" />
<p className="text-center">{error}</p>
<Button variant="outline" size="sm" onClick={() => void resolve()}>
{t("webEndpoint.retry")}
</Button>
</div>
);
}
return (
<div className="flex h-full flex-col">
<div className="flex items-center gap-2 border-b border-border px-2 py-1 text-xs">
<Button
variant="ghost"
size="icon-sm"
onClick={() => void resolve()}
title={t("webEndpoint.reload")}
>
<RotateCw />
</Button>
<span className="truncate text-muted-foreground">{url ?? ""}</span>
<Button
variant="ghost"
size="xs"
className="ml-auto"
onClick={() => {
if (!url) return;
void copyToClipboard(url).then((ok) => {
if (ok) toast.success(t("webEndpoint.urlCopied"));
else toast.error(t("webEndpoint.copyFailed"));
});
}}
>
{t("webEndpoint.copyUrl")}
</Button>
{isElectron() && (
<Button
variant="ghost"
size="xs"
onClick={() => {
void openWebEndpointExternally(host, endpoint).catch((error) =>
toast.error(error.message),
);
}}
>
{t("webEndpoint.openIsolatedWindow")}
</Button>
)}
</div>
<p className="px-2 py-1 text-xs text-muted-foreground">
{t("webEndpoint.isolationNotice")}
</p>
{url && (
// Keyed on generation as well as url: a re-resolved tunnel usually
// returns the SAME port, so url alone would not change and React would
// never remount the frame.
<iframe
key={`${generation}:${url}`}
title={endpoint.label}
ref={(frame) => {
if (!frame) return;
// Set isolation before the first navigation, including redirects.
(
frame as HTMLIFrameElement & { credentialless: boolean }
).credentialless = true;
frame.src = url;
}}
sandbox="allow-scripts allow-forms"
referrerPolicy="no-referrer"
className="h-full w-full flex-1 border-0"
/>
)}
</div>
);
}
+39
View File
@@ -0,0 +1,39 @@
/**
* Frontend half of the web-endpoint plugin.
*
* Registers the endpoint viewer tab through the existing registerTabComponent
* seam the same way network-topology's frontend does.
*
* HostEditorWebUiSection stays in src/ui/sidebar/ rather than moving here, and
* is not registered through registerHostEditorTab: it is a section inside the
* host editor's existing General tab, not a tab of its own, and it takes the
* same {form, setField} pair every other host editor field does -- those types
* (HostEditorForm, the setField signature) are core types HostEditor.tsx owns,
* the same reason docker leaves HostDockerTab in core rather than copying it
* into plugins/docker.
*
* register() is called when the plugin is enabled, unregister() when it is
* disabled. As of this change neither is actually invoked anywhere yet -- see
* src/ui/shell/pluginLoader.ts and
* src/ui/tests/sidebar/plugin-extension-seam.test.tsx, which says outright
* "there is no plugin loader yet" for the frontend half. Until one exists the
* tab is rendered directly by tabUtils.tsx and merely gated on the plugin's
* enabled state through BUILT_IN_TABS_BY_PLUGIN. This file follows the same
* shape every other plugin's frontend entry does so it is ready the moment a
* real loader lands.
*/
export const id = "web-endpoint";
export const tabId = "web-endpoint";
export async function register({ registerTabComponent }) {
registerTabComponent?.(tabId, () =>
import("./WebEndpointTab.tsx").then((m) => ({
default: m.WebEndpointTab,
})),
);
}
export async function unregister({ unregisterTabComponent }) {
unregisterTabComponent?.(tabId);
}
@@ -0,0 +1,160 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { WebEndpoint } from "@/types/index";
const isElectron = vi.hoisted(() => vi.fn(() => false));
vi.mock("@/lib/electron", () => ({ isElectron }));
const tunnelPost = vi.hoisted(() => vi.fn());
vi.mock("@/main-axios", () => ({
tunnelApi: { post: tunnelPost },
handleApiError: (error: unknown) => {
throw new Error(`generic: ${String(error)}`);
},
}));
function endpoint(overrides: Partial<WebEndpoint> = {}): WebEndpoint {
return {
id: "e1",
label: "Proxmox",
scheme: "https",
port: 8006,
path: "/",
access: "tunnel",
render: "external",
bindHost: "0.0.0.0",
...overrides,
};
}
const host = { id: "7", ip: "192.168.1.10" };
let pageHostname = "localhost";
const realLocation = window.location;
const windowOpen = vi.fn();
beforeEach(() => {
pageHostname = "localhost";
isElectron.mockReturnValue(false);
tunnelPost.mockReset();
tunnelPost.mockResolvedValue({ data: { port: 41234 } });
Object.defineProperty(window, "location", {
configurable: true,
get: () => ({ ...realLocation, hostname: pageHostname }),
});
vi.stubGlobal("open", windowOpen);
});
afterEach(() => {
Object.defineProperty(window, "location", {
configurable: true,
value: realLocation,
});
vi.unstubAllGlobals();
windowOpen.mockReset();
});
describe("openWebEndpointTunnel", () => {
it("posts a path relative to the tunnel base, never one starting /ssh", async () => {
// tunnelApi's baseURL already includes /ssh; a leading "/ssh" here would
// resolve to /ssh/ssh/... and 404 on every call. Asserted on the captured
// runtime argument rather than by scanning source, so quoting style and
// indirection cannot fool it.
const { openWebEndpointTunnel } = await import("./web-endpoint-api");
await openWebEndpointTunnel(7, "e1");
expect(tunnelPost.mock.calls[0][0]).not.toMatch(/^\/ssh\//);
expect(tunnelPost).toHaveBeenCalledWith("/tunnel/web-endpoint/open", {
hostId: 7,
endpointId: "e1",
});
});
it("returns the port", async () => {
const { openWebEndpointTunnel } = await import("./web-endpoint-api");
await expect(openWebEndpointTunnel(7, "e1")).resolves.toBe(41234);
});
it("preserves the backend's reason instead of the generic message", async () => {
// 502 is this route's likeliest real failure and carries the actionable
// cause. Collapsing it into "Server error occurred" defeats the
// per-failure-mode messaging entirely.
const axiosError = Object.assign(new Error("Request failed"), {
isAxiosError: true,
response: {
status: 502,
data: { error: "Timed out reaching the endpoint port" },
},
});
tunnelPost.mockRejectedValue(axiosError);
const { openWebEndpointTunnel, WebEndpointTunnelError } =
await import("./web-endpoint-api");
await expect(openWebEndpointTunnel(7, "e1")).rejects.toThrow(
/Timed out reaching the endpoint port/,
);
await expect(openWebEndpointTunnel(7, "e1")).rejects.toBeInstanceOf(
WebEndpointTunnelError,
);
});
it("falls back to the shared handler when the body carries no string reason", async () => {
// A body shaped { error: <object> } would otherwise render as
// "[object Object]" to the user.
tunnelPost.mockRejectedValue(
Object.assign(new Error("boom"), {
isAxiosError: true,
response: { status: 500, data: { error: { nested: true } } },
}),
);
const { openWebEndpointTunnel } = await import("./web-endpoint-api");
await expect(openWebEndpointTunnel(7, "e1")).rejects.toThrow(/generic:/);
});
it("treats a missing port as a failure rather than returning undefined", async () => {
tunnelPost.mockResolvedValue({ data: {} });
const { openWebEndpointTunnel } = await import("./web-endpoint-api");
await expect(openWebEndpointTunnel(7, "e1")).rejects.toThrow(/no port/);
});
});
describe("requireNumericHostId", () => {
it("accepts a saved host id and rejects a quick-connect one", async () => {
const { requireNumericHostId } = await import("./web-endpoint-api");
expect(requireNumericHostId("7")).toBe(7);
for (const bad of ["quick-connect-1", "", "0", "-3", "abc"]) {
expect(() => requireNumericHostId(bad)).toThrow(/saved host/);
}
});
});
/**
* "Open externally" is not the safer path. The cookie jar belongs to the
* browser either way, so a tunnel URL on the host string serving Termix hands
* the tunnelled service this session exactly as an embedded frame would.
*/
describe("openWebEndpointExternally", () => {
it("refuses shared-cookie browser windows before opening a tunnel", async () => {
const { openWebEndpointExternally } = await import("./web-endpoint-api");
await expect(openWebEndpointExternally(host, endpoint())).rejects.toThrow(
/desktop app/,
);
expect(windowOpen).not.toHaveBeenCalled();
expect(tunnelPost).not.toHaveBeenCalled();
});
it("opens desktop endpoints through the isolated-window bridge", async () => {
isElectron.mockReturnValue(true);
const invoke = vi.fn().mockResolvedValue({ success: true });
Object.defineProperty(window, "electronAPI", {
configurable: true,
value: { invoke },
});
const { openWebEndpointExternally } = await import("./web-endpoint-api");
await openWebEndpointExternally(host, endpoint({ ignoreCert: true }));
expect(invoke).toHaveBeenCalledWith("open-isolated-web-endpoint", {
url: "https://127.0.0.1:41234/",
ignoreCert: true,
});
expect(windowOpen).not.toHaveBeenCalled();
delete (window as unknown as { electronAPI?: unknown }).electronAPI;
});
});
@@ -0,0 +1,136 @@
import { currentTunnelHost, resolveWebEndpointUrl } from "./web-endpoint-url";
import axios from "axios";
import { handleApiError, tunnelApi } from "@/main-axios";
import { isElectron } from "@/lib/electron";
import type { WebEndpoint } from "@/types/index";
/**
* Thrown when the backend rejects a web endpoint tunnel open with a specific,
* actionable reason -- most commonly a 502 carrying the real cause: SSH auth
* rejected, host unreachable, or nothing listening on the target port.
* Preserves that reason instead of letting handleApiError collapse three
* actionable errors into "Server error occurred. Please try again later."
*/
export class WebEndpointTunnelError extends Error {
constructor(
message: string,
public readonly status?: number,
) {
super(message);
this.name = "WebEndpointTunnelError";
}
}
/**
* Opens (or reuses) the SSH forward for a tunnel-access web endpoint and
* returns the port it is listening on. The port is NOT stable across opens:
* the backend closes an idle tunnel and re-binds a fresh kernel-assigned port,
* so callers must re-resolve rather than cache it.
*/
export async function openWebEndpointTunnel(
hostId: number,
endpointId: string,
): Promise<number> {
try {
// Relative to the tunnel API base, which already includes /ssh
// (getApiUrl("/ssh", 30003)). A path beginning "/ssh" here would resolve
// to /ssh/ssh/... and 404 on every call.
const response = await tunnelApi.post("/tunnel/web-endpoint/open", {
hostId,
endpointId,
});
const port = (response?.data as { port?: number } | undefined)?.port;
if (!port) {
throw new Error("The endpoint tunnel returned no port");
}
return port;
} catch (error) {
if (axios.isAxiosError(error)) {
// Typed unknown, not string: the guard below is only meaningful if the
// type does not already promise what it checks.
const data = error.response?.data as
{ error?: unknown; message?: unknown } | undefined;
const backendMessage = data?.error ?? data?.message;
// A body shaped { error: <object> } would otherwise render as
// "[object Object]". Every response on this route sends a string today,
// but a proxy or a future handler need not.
if (typeof backendMessage === "string" && backendMessage) {
throw new WebEndpointTunnelError(
backendMessage,
error.response?.status,
);
}
}
return handleApiError(error, "open web endpoint tunnel");
}
}
/**
* Registers one exact origin as allowed to present an invalid TLS certificate.
* Only meaningful on the desktop, and only for direct endpoints -- a tunnel
* endpoint's host component is loopback, which the main process already
* exempts.
*/
export async function allowInvalidCertificateForOrigin(
origin: string,
): Promise<void> {
if (!isElectron()) return;
try {
await window.electronAPI?.invoke?.(
"allow-invalid-certificate-for-origin",
origin,
);
} catch {
// The user's build may predate the handler; the load then simply fails the
// certificate check as it would have before.
}
}
/**
* A web endpoint tunnel opens through the backend's numeric host id.
* Quick-connect hosts (ids like "quick-connect-<n>") have no row on the
* server, so there is nothing to open a tunnel through -- unlike a direct
* endpoint, which never touches the backend and works regardless.
*/
export function requireNumericHostId(id: string): number {
const numericId = Number(id);
if (!Number.isInteger(numericId) || numericId <= 0) {
throw new Error(
"This endpoint needs an SSH tunnel, which requires a saved host",
);
}
return numericId;
}
/** Desktop windows use a dedicated ephemeral session, including login popups. */
export async function openWebEndpointExternally(
host: { id: string; ip: string },
endpoint: WebEndpoint,
): Promise<void> {
if (!isElectron() || !window.electronAPI?.invoke) {
throw new Error(
"Isolated windows require the desktop app. Choose Embedded in the endpoint settings.",
);
}
const localPort =
endpoint.access === "tunnel"
? await openWebEndpointTunnel(requireNumericHostId(host.id), endpoint.id)
: undefined;
const url = resolveWebEndpointUrl({
hostAddress: host.ip,
endpoint,
localPort,
tunnelHost: currentTunnelHost(true) ?? undefined,
});
const result = await window.electronAPI.invoke("open-isolated-web-endpoint", {
url,
ignoreCert: endpoint.ignoreCert === true,
});
if (
!result ||
typeof result !== "object" ||
!("success" in result) ||
result.success !== true
)
throw new Error("Failed to open isolated web endpoint");
}
@@ -0,0 +1,367 @@
import { describe, expect, it } from "vitest";
import {
currentTunnelHost,
resolveWebEndpointUrl,
separatedTunnelHost,
sharesCookieSiteWithPage,
webEndpointRefusalReason,
} from "./web-endpoint-url";
import type { WebEndpoint } from "@/types/index";
function endpoint(overrides: Partial<WebEndpoint> = {}): WebEndpoint {
return {
id: "e1",
label: "Proxmox",
scheme: "https",
port: 8006,
access: "direct",
render: "external",
...overrides,
};
}
describe("resolveWebEndpointUrl", () => {
it("builds a direct URL at the host address", () => {
expect(
resolveWebEndpointUrl({
hostAddress: "192.168.1.10",
endpoint: endpoint(),
}),
).toBe("https://192.168.1.10:8006/");
});
it("builds a direct URL for a hostname", () => {
expect(
resolveWebEndpointUrl({
hostAddress: "nas.local",
endpoint: endpoint({ port: 443 }),
}),
).toBe("https://nas.local:443/");
});
it("uses the local port for tunnel access, dropping the endpoint's own port", () => {
expect(
resolveWebEndpointUrl({
hostAddress: "192.168.1.10",
endpoint: endpoint({ access: "tunnel", scheme: "http", port: 8006 }),
localPort: 41234,
}),
).toBe("http://127.0.0.1:41234/");
});
it("defaults a missing path to /", () => {
expect(
resolveWebEndpointUrl({
hostAddress: "10.0.0.5",
endpoint: endpoint({ scheme: "http", port: 80, path: undefined }),
}),
).toBe("http://10.0.0.5:80/");
});
it("keeps an explicit path and its query", () => {
expect(
resolveWebEndpointUrl({
hostAddress: "10.0.0.5",
endpoint: endpoint({
scheme: "http",
port: 9000,
path: "/admin?tab=1",
}),
}),
).toBe("http://10.0.0.5:9000/admin?tab=1");
});
it("throws when tunnel access has no local port", () => {
expect(() =>
resolveWebEndpointUrl({
hostAddress: "10.0.0.5",
endpoint: endpoint({ access: "tunnel" }),
}),
).toThrow(/local port/i);
});
it("brackets a bare IPv6 host address", () => {
expect(
resolveWebEndpointUrl({
hostAddress: "fd00::1",
endpoint: endpoint({ scheme: "http", port: 8080 }),
}),
).toBe("http://[fd00::1]:8080/");
});
it("brackets a bare IPv6 tunnel host", () => {
expect(
resolveWebEndpointUrl({
hostAddress: "10.0.0.5",
endpoint: endpoint({ access: "tunnel", scheme: "http" }),
localPort: 41234,
tunnelHost: "fd00::1",
}),
).toBe("http://[fd00::1]:41234/");
});
it("uses an explicit tunnel host when given", () => {
expect(
resolveWebEndpointUrl({
hostAddress: "10.0.0.5",
endpoint: endpoint({ access: "tunnel", scheme: "http" }),
localPort: 41234,
tunnelHost: "termix.lan",
}),
).toBe("http://termix.lan:41234/");
});
});
/**
* Cookies are keyed by host and IGNORE the port, and SameSite computes "site"
* as scheme + registrable domain -- also ignoring the port. So a forward
* reached at the very host string serving Termix is same-site with Termix:
* the browser attaches the jwt cookie to every request the framed
* third-party server receives, and a Set-Cookie from that server lands in the
* jar Termix's own API calls read from (both auth middlewares prefer the
* cookie over the Authorization header).
*
* Both directions were reproduced against a real SSH forward. separatedTunnelHost
* is the whole mitigation: a host string that reaches the same machine but is
* a DIFFERENT cookie-jar key.
*/
describe("separatedTunnelHost", () => {
it("maps each loopback spelling to a different one", () => {
expect(separatedTunnelHost("localhost")).toBe("127.0.0.1");
expect(separatedTunnelHost("127.0.0.1")).toBe("localhost");
expect(separatedTunnelHost("::1")).toBe("127.0.0.1");
expect(separatedTunnelHost("[::1]")).toBe("127.0.0.1");
});
it("never returns the host it was given, which would be no separation", () => {
for (const host of ["localhost", "127.0.0.1", "::1", "[::1]"]) {
expect(separatedTunnelHost(host)).not.toBe(host);
}
});
it("is case- and whitespace-insensitive", () => {
expect(separatedTunnelHost(" LOCALHOST ")).toBe("127.0.0.1");
});
it("returns null for a host with no alias, rather than guessing", () => {
// A same-registrable-domain alias would NOT be safe: the target could
// answer Set-Cookie with Domain=example.com and reach Termix anyway.
// Only loopback literals qualify -- they cannot carry a Domain.
expect(separatedTunnelHost("termix.example.com")).toBeNull();
expect(separatedTunnelHost("192.168.1.50")).toBeNull();
expect(separatedTunnelHost("")).toBeNull();
});
});
describe("currentTunnelHost", () => {
it("is loopback on the desktop, whose API base is localhost", () => {
// Two different cookie-jar keys. Do not "simplify" either to match.
expect(currentTunnelHost(true)).toBe("127.0.0.1");
});
it("swaps to the other loopback spelling when Termix is served at localhost", () => {
expect(currentTunnelHost(false, "localhost")).toBe("127.0.0.1");
});
it("swaps the other way when Termix is served at 127.0.0.1", () => {
expect(currentTunnelHost(false, "127.0.0.1")).toBe("localhost");
});
it("has no answer for a page host with no loopback alias", () => {
expect(currentTunnelHost(false, "termix.example.com")).toBeNull();
expect(currentTunnelHost(false, "192.168.1.50")).toBeNull();
});
});
/**
* The cookie leak is not tunnel-only. A DIRECT endpoint points at the host's
* own address, and when that host is the same one serving Termix in a browser
* (a different port is not a different cookie key), the framed third-party
* service receives Termix's `jwt` -- and can set one back. So the refusal has
* to cover direct access too, using the endpoint's target host rather than a
* bind address.
*/
describe("sharesCookieSiteWithPage", () => {
it("is true for the exact same host, because cookies ignore the port", () => {
expect(sharesCookieSiteWithPage("termix.example", "termix.example")).toBe(
true,
);
});
it("is true for a parent or sub domain, which a Domain-scoped cookie crosses", () => {
expect(
sharesCookieSiteWithPage("app.termix.example", "termix.example"),
).toBe(true);
expect(
sharesCookieSiteWithPage("termix.example", "app.termix.example"),
).toBe(true);
});
it("is false for a genuinely different host", () => {
expect(sharesCookieSiteWithPage("192.168.1.10", "termix.example")).toBe(
false,
);
expect(sharesCookieSiteWithPage("nas.local", "termix.example")).toBe(false);
});
it("is case- and whitespace-insensitive and ignores a trailing dot", () => {
expect(
sharesCookieSiteWithPage(" TERMIX.example. ", "termix.example"),
).toBe(true);
});
it("does not treat a shared suffix off a label boundary as same-site", () => {
// "eviltermix.example" ends with "termix.example" as a string but not on a
// dot boundary -- treating it as same-site would let an attacker pick such
// a name to dodge the guard, so it must be false.
expect(
sharesCookieSiteWithPage("eviltermix.example", "termix.example"),
).toBe(false);
});
it("is false for blank input rather than guessing", () => {
expect(sharesCookieSiteWithPage("", "termix.example")).toBe(false);
expect(sharesCookieSiteWithPage("termix.example", "")).toBe(false);
});
});
describe("webEndpointRefusalReason", () => {
const tunnel = (bindHost?: string) =>
endpoint({ access: "tunnel", bindHost }) as Pick<
WebEndpoint,
"access" | "bindHost"
>;
const direct = () =>
endpoint({ access: "direct" }) as Pick<WebEndpoint, "access" | "bindHost">;
it("refuses a direct endpoint on the same host that serves Termix", () => {
// https://termix.example:8443 receives Termix's jwt: same host, and the
// port is not part of the cookie key.
expect(
webEndpointRefusalReason(
direct(),
false,
"termix.example",
"termix.example",
),
).toBe("direct-shares-session-cookie");
});
it("refuses a direct endpoint on a parent or sub domain of the page host", () => {
expect(
webEndpointRefusalReason(
direct(),
false,
"termix.example.com",
"app.termix.example.com",
),
).toBe("direct-shares-session-cookie");
expect(
webEndpointRefusalReason(
direct(),
false,
"app.termix.example.com",
"termix.example.com",
),
).toBe("direct-shares-session-cookie");
});
it("allows a direct endpoint on a genuinely different host", () => {
expect(
webEndpointRefusalReason(
direct(),
false,
"192.168.1.10",
"termix.example",
),
).toBeNull();
expect(
webEndpointRefusalReason(direct(), false, "nas.local", "termix.example"),
).toBeNull();
});
it("allows a direct same-host endpoint on the desktop, whose jar has no jwt", () => {
expect(
webEndpointRefusalReason(
direct(),
true,
"termix.example",
"termix.example",
),
).toBeNull();
});
it("passes anything on the desktop", () => {
expect(
webEndpointRefusalReason(tunnel(), true, undefined, "localhost"),
).toBeNull();
expect(
webEndpointRefusalReason(tunnel("0.0.0.0"), true, undefined, "localhost"),
).toBeNull();
});
it("refuses a loopback bind a remote backend would strand", () => {
expect(
webEndpointRefusalReason(tunnel(), false, undefined, "localhost"),
).toBe("loopback-bind-on-remote-backend");
expect(
webEndpointRefusalReason(
tunnel("127.0.0.1"),
false,
undefined,
"localhost",
),
).toBe("loopback-bind-on-remote-backend");
});
it("allows an exposed bind when the page host has a loopback alias", () => {
expect(
webEndpointRefusalReason(
tunnel("0.0.0.0"),
false,
undefined,
"localhost",
),
).toBeNull();
expect(
webEndpointRefusalReason(
tunnel("0.0.0.0"),
false,
undefined,
"127.0.0.1",
),
).toBeNull();
});
it("refuses when the tunnel URL would share Termix's cookie jar", () => {
expect(
webEndpointRefusalReason(
tunnel("0.0.0.0"),
false,
undefined,
"termix.example.com",
),
).toBe("shares-session-cookie-with-termix");
expect(
webEndpointRefusalReason(
tunnel("0.0.0.0"),
false,
undefined,
"192.168.1.50",
),
).toBe("shares-session-cookie-with-termix");
});
it("reports the reachability problem first when both apply", () => {
// The bind address is what the user has to change either way, and it is
// the more specific instruction.
expect(
webEndpointRefusalReason(
tunnel(),
false,
undefined,
"termix.example.com",
),
).toBe("loopback-bind-on-remote-backend");
});
});
@@ -0,0 +1,211 @@
import type { WebEndpoint } from "@/types/index";
/**
* Builds the URL a web endpoint opens at.
*
* Direct access points at the host's own address. Tunnel access points at a
* port the backend has forwarded to the endpoint's port on the host -- so the
* endpoint's own port is deliberately absent from a tunnel URL.
*
* Expects an endpoint whose `path` has already been normalized by
* `normalizeWebEndpoints` (it always begins with "/"); a raw unvalidated path
* would produce a malformed URL.
*/
export function resolveWebEndpointUrl({
hostAddress,
endpoint,
localPort,
tunnelHost,
}: {
hostAddress: string;
endpoint: WebEndpoint;
localPort?: number;
/**
* Where the caller can reach the forward the backend bound. Must never be
* the host string the browser is currently reaching Termix at -- see
* `separatedTunnelHost`. Defaults to loopback.
*/
tunnelHost?: string;
}): string {
const path = endpoint.path && endpoint.path.length > 0 ? endpoint.path : "/";
if (endpoint.access === "tunnel") {
if (!localPort) {
throw new Error("A tunnel endpoint needs a local port to resolve a URL");
}
const authority = bracketIfIpv6(tunnelHost?.trim() || "127.0.0.1");
return `${endpoint.scheme}://${authority}:${localPort}${path}`;
}
return `${endpoint.scheme}://${bracketIfIpv6(hostAddress)}:${endpoint.port}${path}`;
}
/** 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;
}
/** Host spellings that mean "this machine". */
const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]);
/**
* Each loopback spelling mapped to a DIFFERENT one that reaches the same
* machine. Being a different string is the entire point.
*/
const LOOPBACK_ALIASES: Record<string, string> = {
localhost: "127.0.0.1",
"127.0.0.1": "localhost",
"::1": "127.0.0.1",
"[::1]": "127.0.0.1",
};
/**
* A host string that reaches the same machine as `pageHost` but is a
* different key in the browser's cookie jar -- or null when none can be
* derived.
*
* This is a security mitigation, not a cosmetic choice. Cookies are keyed by
* host and IGNORE the port, and SameSite computes "site" as scheme +
* registrable domain -- also ignoring the port. So a forward reached at the
* very host string serving Termix is same-site with Termix, and two things
* follow:
*
* 1. the browser attaches Termix's `jwt` cookie to every request the framed
* third-party server sees, including its access log; and
* 2. a `Set-Cookie: jwt=...` from that server lands in the jar Termix's own
* API calls read from -- and both auth middlewares read the cookie
* BEFORE the Authorization header, so it decides who Termix acts as.
*
* Both directions were reproduced against a real SSH forward.
*
* Only loopback literals get an alias. A same-registrable-domain name would
* not be safe even though it is a different host: the target could answer
* `Set-Cookie: jwt=...; Domain=example.com`, which reaches Termix anyway.
* Loopback literals cannot carry a Domain attribute, so they are the only
* spellings that close both directions.
*/
export function separatedTunnelHost(pageHost: string): string | null {
return LOOPBACK_ALIASES[pageHost.trim().toLowerCase()] ?? null;
}
/** Lowercase, trim, drop surrounding IPv6 brackets and a single trailing dot. */
function normalizeHost(host: string): string {
let h = host.trim().toLowerCase();
if (h.startsWith("[") && h.endsWith("]")) h = h.slice(1, -1);
if (h.endsWith(".")) h = h.slice(0, -1);
return h;
}
/**
* Whether a browser would attach Termix's `jwt` cookie to a request for
* `targetHost` while the page is served from `pageHost` -- or accept a
* `Set-Cookie` from `targetHost` into the jar Termix's own API reads.
*
* The web client sets `jwt` host-only (no `Domain`), so the port is irrelevant
* and the disclosure direction is exactly an equal host. The reverse direction
* -- the target answering `Set-Cookie: jwt=...; Domain=<parent>` -- reaches
* Termix whenever the two share a domain, so a parent/sub relationship counts
* too. The suffix check is anchored on a dot boundary so `eviltermix.example`
* is not treated as same-site with `termix.example`.
*
* A precise registrable-domain (eTLD+1) test would also catch sibling
* subdomains under a shared parent, but needs a public-suffix list this app
* does not bundle; the exact/parent/sub check closes the reported same-host
* case only. This is defense in depth, not cookie isolation: embedded frames
* must use credentialless plus an opaque sandbox origin, including redirects.
*/
export function sharesCookieSiteWithPage(
targetHost: string,
pageHost: string,
): boolean {
const t = normalizeHost(targetHost);
const p = normalizeHost(pageHost);
if (!t || !p) return false;
if (t === p) return true;
return t.endsWith("." + p) || p.endsWith("." + t);
}
function defaultPageHost(): string {
return typeof window === "undefined" ? "127.0.0.1" : window.location.hostname;
}
/**
* Why a web endpoint must not be opened from this client.
*
* `loopback-bind-on-remote-backend` -- the forward binds where the backend
* runs. In a browser that is the server, so a loopback bind answers only to
* the server itself: the open succeeds, the port exists, and the browser
* still gets nothing.
*
* `shares-session-cookie-with-termix` -- the tunnel URL would resolve to the
* same host string the page is served from, handing Termix's session token to
* the tunnelled service. See `separatedTunnelHost`.
*
* `direct-shares-session-cookie` -- the same leak without a tunnel: a direct
* endpoint's target host shares Termix's cookie site, so the browser attaches
* the `jwt` to it (and accepts one back). A different port is not a different
* cookie key. See `sharesCookieSiteWithPage`.
*/
export type WebEndpointRefusalReason =
| "loopback-bind-on-remote-backend"
| "shares-session-cookie-with-termix"
| "direct-shares-session-cookie";
/**
* Why a web endpoint must not be opened from this client, or null when it may.
*
* Covers both access modes. A tunnel is refused when its forward would either
* be unreachable (loopback bind on a remote backend) or resolve to the page's
* own host string (`shares-session-cookie-with-termix`). A DIRECT endpoint is
* refused when its target host shares Termix's cookie site
* (`direct-shares-session-cookie`) -- the same leak, reached without a tunnel:
* cookies ignore the port, so a UI on the very host serving Termix receives
* the session. `hostAddress` is the direct endpoint's target host and is
* ignored for tunnels.
*
* The desktop is exempt: its session is Bearer-only with no `jwt` in the jar,
* and its API base ("localhost") is already separated from where tunnels
* resolve ("127.0.0.1").
*/
export function webEndpointRefusalReason(
endpoint: Pick<WebEndpoint, "access" | "bindHost">,
runningInElectron: boolean,
hostAddress: string | undefined,
pageHost: string = defaultPageHost(),
): WebEndpointRefusalReason | null {
if (runningInElectron) return null;
if (endpoint.access === "direct") {
if (hostAddress && sharesCookieSiteWithPage(hostAddress, pageHost)) {
return "direct-shares-session-cookie";
}
return null;
}
if (endpoint.access !== "tunnel") return null;
const bindHost = (endpoint.bindHost ?? "").trim().toLowerCase();
// Reported first when both apply: the bind address is what the user has to
// change either way, and it is the more specific instruction.
if (!bindHost || LOOPBACK_HOSTS.has(bindHost)) {
return "loopback-bind-on-remote-backend";
}
if (!separatedTunnelHost(pageHost)) {
return "shares-session-cookie-with-termix";
}
return null;
}
/**
* Where this client should reach a forward the backend bound, or null when no
* host string is both reachable and safe -- in which case
* `unreachableTunnelReason` explains why and the caller must not open.
*/
export function currentTunnelHost(
runningInElectron: boolean,
pageHost: string = defaultPageHost(),
): string | null {
if (runningInElectron) return "127.0.0.1";
return separatedTunnelHost(pageHost);
}
@@ -0,0 +1,137 @@
import { describe, expect, it } from "vitest";
import {
findInvalidWebEndpoint,
isWebEndpointPathValid,
isWebEndpointPortValid,
webEndpointErrorKey,
webEndpointRowError,
} from "./web-endpoint-validation";
import { normalizeWebEndpoints } from "../../../src/backend/database/routes/host-web-endpoints.js";
import type { WebEndpoint } from "@/types/index";
function endpoint(overrides: Partial<WebEndpoint> = {}): WebEndpoint {
return {
id: "e1",
label: "Proxmox",
scheme: "https",
port: 8006,
path: "/",
access: "direct",
render: "external",
...overrides,
};
}
describe("isWebEndpointPortValid", () => {
it("accepts the whole legal range and nothing else", () => {
expect(isWebEndpointPortValid(1)).toBe(true);
expect(isWebEndpointPortValid(65535)).toBe(true);
for (const bad of [0, -1, 65536, 8.5, "8006", null, undefined, NaN]) {
expect(isWebEndpointPortValid(bad)).toBe(false);
}
});
});
describe("webEndpointRowError", () => {
it("returns null for a row the normalizer would keep", () => {
expect(webEndpointRowError(endpoint())).toBeNull();
});
it("reports a blank label before anything else", () => {
// Both label and port are wrong; the label is the likelier oversight.
expect(webEndpointRowError(endpoint({ label: " ", port: 0 }))).toBe(
"label",
);
});
it("reports a bad port and a bad path", () => {
expect(webEndpointRowError(endpoint({ port: 0 }))).toBe("port");
expect(webEndpointRowError(endpoint({ path: "//evil.example" }))).toBe(
"path",
);
});
});
describe("findInvalidWebEndpoint", () => {
it("returns null when every row would survive", () => {
expect(
findInvalidWebEndpoint([
endpoint(),
endpoint({ id: "e2", label: "NAS" }),
]),
).toBeNull();
});
it("names the offending row by index", () => {
expect(
findInvalidWebEndpoint([endpoint(), endpoint({ id: "e2", label: "" })]),
).toEqual({ index: 1, error: "label" });
});
it("reports duplicate labels, which the picker cannot tell apart", () => {
expect(
findInvalidWebEndpoint([
endpoint(),
endpoint({ id: "e2", label: "proxmox" }),
]),
).toEqual({ index: 1, error: "duplicateLabel" });
});
it("tolerates a missing list", () => {
expect(findInvalidWebEndpoint(undefined)).toBeNull();
expect(findInvalidWebEndpoint(null)).toBeNull();
});
});
describe("webEndpointErrorKey", () => {
it("maps every error to a distinct i18n key", () => {
const keys = (["label", "duplicateLabel", "port", "path"] as const).map(
webEndpointErrorKey,
);
expect(new Set(keys).size).toBe(keys.length);
for (const key of keys) expect(key).toMatch(/^hosts\./);
});
});
/**
* The editor validator and the backend normalizer are two implementations of
* overlapping rules, in two layers that cannot share code (the renderer does
* not import backend route modules). Nothing but this describe block stops
* them drifting -- and drift here is invisible in the worst way: the editor
* accepts a row, the normalizer silently drops it, and the user's endpoint
* disappears on reload with no error anywhere.
*/
describe("the editor agrees with the normalizer", () => {
const PATHS = [
"/",
"",
"/admin",
"admin",
"/admin?tab=1",
"/a b",
"//evil.example",
"http://evil.example/x",
"/x:y",
"\t//evil.example",
"\n//evil.example",
"/a\tb",
`/a${String.fromCharCode(0x7f)}b`,
"/back\\slash",
];
it.each(PATHS)("agrees on path %j", (path) => {
const editorAccepts = isWebEndpointPathValid(path);
const normalizerKeeps =
normalizeWebEndpoints([{ ...endpoint(), path }]).length === 1;
expect(editorAccepts).toBe(normalizerKeeps);
});
const PORTS = [1, 80, 8006, 65535, 0, -1, 65536, 8.5];
it.each(PORTS)("agrees on port %j", (port) => {
const editorAccepts = isWebEndpointPortValid(port);
const normalizerKeeps =
normalizeWebEndpoints([{ ...endpoint(), port }]).length === 1;
expect(editorAccepts).toBe(normalizerKeeps);
});
});
@@ -0,0 +1,121 @@
import type { WebEndpoint } from "@/types/index";
/**
* Editor-side validation for web endpoint rows.
*
* The authority on what a stored endpoint may look like is
* `normalizeWebEndpoints` in
* src/backend/database/routes/host-web-endpoints.ts, and it DROPS any row it
* refuses rather than reporting it -- so without a check here the user adds a
* row, saves, sees no error, and finds the endpoint gone on reload.
*
* This deliberately does NOT restate that module's full rule set, and does not
* import it: the renderer does not import backend route modules (the same
* reason MAX_WEB_ENDPOINTS lives in src/types rather than beside the
* validator). It covers only the conditions a user can reach by typing.
* src/ui/tests/lib/web-endpoint-validation.test.ts runs both implementations
* over the same samples so the two cannot drift apart silently.
*/
export const MIN_WEB_ENDPOINT_PORT = 1;
export const MAX_WEB_ENDPOINT_PORT = 65535;
export type WebEndpointFieldError =
"label" | "duplicateLabel" | "port" | "path";
const ERROR_KEYS: Record<WebEndpointFieldError, string> = {
label: "hosts.webUiErrorLabelRequired",
duplicateLabel: "hosts.webUiErrorLabelDuplicate",
port: "hosts.webUiErrorPortRange",
path: "hosts.webUiErrorPathInvalid",
};
/**
* The i18n key for a field error. Every key takes a `row` interpolation so the
* message can say which endpoint is at fault.
*/
export function webEndpointErrorKey(error: WebEndpointFieldError): string {
return ERROR_KEYS[error];
}
export function isWebEndpointPortValid(port: unknown): boolean {
return (
typeof port === "number" &&
Number.isInteger(port) &&
port >= MIN_WEB_ENDPOINT_PORT &&
port <= MAX_WEB_ENDPOINT_PORT
);
}
function hasControlCharacter(value: string): boolean {
for (let index = 0; index < value.length; index += 1) {
const code = value.charCodeAt(index);
if (code <= 0x1f || code === 0x7f) return true;
}
return false;
}
/**
* True if the normalizer would accept this path. An empty or absent path is
* fine -- the normalizer defaults it to "/" -- and so is a path with no
* leading slash, which the normalizer coerces.
*/
export function isWebEndpointPathValid(
raw: string | undefined | null,
): boolean {
if (raw === undefined || raw === null || raw === "") return true;
if (hasControlCharacter(raw)) return false;
const path = raw.startsWith("/") ? raw : `/${raw}`;
// A protocol-relative or absolute path would send the iframe or the link
// somewhere other than the endpoint.
if (path.startsWith("//")) return false;
if (/^\/[a-z][a-z0-9+.-]*:/i.test(path)) return false;
if (path.includes("\\")) return false;
return true;
}
/**
* The first thing wrong with one row, or null if the normalizer would keep it.
* Label is checked before port so the message points at the field the user
* most likely just left blank.
*/
export function webEndpointRowError(
endpoint: WebEndpoint,
): WebEndpointFieldError | null {
if (typeof endpoint.label !== "string" || endpoint.label.trim() === "") {
return "label";
}
if (!isWebEndpointPortValid(endpoint.port)) return "port";
if (!isWebEndpointPathValid(endpoint.path)) return "path";
return null;
}
/**
* The first row the normalizer would drop, so a save can be blocked with a
* message naming it. Returns null when every row would survive.
*
* Duplicate LABELS are reported even though the normalizer tolerates them:
* the sidebar picker shows labels, so two identical ones are indistinguishable
* to the user. Duplicate ids are generated, never typed.
*/
export function findInvalidWebEndpoint(
endpoints: WebEndpoint[] | undefined | null,
): { index: number; error: WebEndpointFieldError } | null {
if (!Array.isArray(endpoints)) return null;
const seen = new Set<string>();
for (let index = 0; index < endpoints.length; index += 1) {
const endpoint = endpoints[index];
const error = webEndpointRowError(endpoint);
if (error) return { index, error };
const label = endpoint.label.trim().toLowerCase();
if (seen.has(label)) return { index, error: "duplicateLabel" };
seen.add(label);
}
return null;
}
+35
View File
@@ -0,0 +1,35 @@
{
"id": "web-endpoint",
"name": "Web Endpoint",
"version": "1.0.0",
"description": "Open a host's web UI in a tab or an isolated desktop window, directly or over an SSH tunnel.",
"author": { "name": "Termix" },
"license": "MIT",
"repository": "https://github.com/Termix-SSH/Plugin-WebEndpoint",
"category": "Networking",
"icon": "Globe",
"engine": { "termix": ">=2.9.0", "api": "1" },
"capabilities": {
"backend": true,
"frontend": true,
"electron": true,
"platforms": ["linux", "win32", "darwin"]
},
"permissions": ["process:transport-owner"],
"contributes": {
"tabs": [
{
"id": "web-endpoint",
"titleKey": "hosts.tabWebUi",
"icon": "Globe",
"openFrom": ["host-context-menu"]
}
],
"hostCapability": {
"key": "enableWebUi",
"labelKey": "hosts.tabWebUi",
"editorTab": "general"
}
},
"sidecars": []
}