mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 22:25:24 +00:00
feat: move web endpoint into a first-party plugin
This commit is contained in:
1 parent
6f0dff58a3
commit
6e058f6e9e
26 files changed
+496
-121
No files matched your search
@@ -146,7 +146,7 @@ function normalizeEndpoint(raw: unknown): WebEndpoint | null {
|
||||
* Drops any endpoint it refuses rather than rejecting the whole host -- one
|
||||
* bad row must not make a host unsaveable or unlistable. The editor is
|
||||
* responsible for telling the user before that happens
|
||||
* (src/ui/lib/web-endpoint-validation.ts).
|
||||
* (plugins/web-endpoint/frontend/web-endpoint-validation.ts).
|
||||
*/
|
||||
export function normalizeWebEndpoints(raw: unknown): WebEndpoint[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
} from "./c2s-relay.js";
|
||||
|
||||
import { registerTunnelRoutes } from "./routes.js";
|
||||
import { registerWebEndpointRoutes } from "./web-endpoint-routes.js";
|
||||
import webEndpointDispatch from "./web-endpoint-dispatch.js";
|
||||
import { initializeAutoStartTunnels } from "./manager.js";
|
||||
import { attachServicePortConflictHandler } from "../../utils/service-listen.js";
|
||||
|
||||
@@ -39,7 +39,9 @@ app.use((_req, res, next) => {
|
||||
});
|
||||
|
||||
registerTunnelRoutes(app);
|
||||
registerWebEndpointRoutes(app);
|
||||
// Always mounted; the Web Endpoint plugin registers its router behind this on
|
||||
// activate and clears it on deactivate. See web-endpoint-dispatch.ts.
|
||||
app.use("/ssh/tunnel/web-endpoint", webEndpointDispatch);
|
||||
|
||||
const PORT = 30003;
|
||||
const server = createServer(app);
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// Mounted at /ssh/tunnel/web-endpoint on the tunnel service's app. Web
|
||||
// Endpoint ships as a first-party in-process plugin (plugins/web-endpoint)
|
||||
// rather than a route file imported directly by index.ts, so this module
|
||||
// exists to keep /ssh/tunnel/web-endpoint/* working at its original path
|
||||
// without handing the plugin a reference to the shared app instance. The
|
||||
// plugin runtime registers its router via registerWebEndpointRouter when it
|
||||
// activates, and clears it on deactivate, so a disabled plugin falls back to
|
||||
// the 404 below.
|
||||
//
|
||||
// This is the only dispatcher that lives outside database.ts: the route is
|
||||
// served by the tunnel service on port 30003, and nginx already proxies the
|
||||
// whole /ssh/tunnel/ prefix there. Both that service and the plugin runtime
|
||||
// are loaded into the same backend process (starter.ts imports
|
||||
// hosts/tunnel/index.js), so the module-level slot below is shared state
|
||||
// between them, exactly as it is for the dispatchers in database.ts.
|
||||
//
|
||||
// Unlike /plugin-api, this dispatcher applies no auth of its own: it forwards
|
||||
// to exactly one first-party router (the Web Endpoint plugin's own), and every
|
||||
// route in that router already applies authenticateJWT itself, the same as
|
||||
// every other route registered alongside it here. /plugin-api gates at the
|
||||
// mount point because it forwards to arbitrary third-party plugin code that
|
||||
// cannot be assumed to gate itself; that reasoning does not apply to a single
|
||||
// first-party router that already does.
|
||||
|
||||
import type { Request, Response, NextFunction, Router } from "express";
|
||||
import { tunnelLogger } from "../../utils/logger.js";
|
||||
|
||||
let activeRouter: Router | null = null;
|
||||
|
||||
export function registerWebEndpointRouter(router: Router): void {
|
||||
activeRouter = router;
|
||||
tunnelLogger.info("Registered Web Endpoint plugin router", {
|
||||
operation: "web_endpoint_dispatch_register",
|
||||
});
|
||||
}
|
||||
|
||||
export function unregisterWebEndpointRouter(): void {
|
||||
if (!activeRouter) return;
|
||||
activeRouter = null;
|
||||
tunnelLogger.info("Unregistered Web Endpoint plugin router", {
|
||||
operation: "web_endpoint_dispatch_register",
|
||||
});
|
||||
}
|
||||
|
||||
export default function webEndpointDispatch(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
): void {
|
||||
if (!activeRouter) {
|
||||
res.status(404).json({ error: "Web endpoints are not available" });
|
||||
return;
|
||||
}
|
||||
activeRouter(req, res, next);
|
||||
}
|
||||
@@ -1,310 +0,0 @@
|
||||
import type express from "express";
|
||||
import { Client } from "ssh2";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { tunnelLogger } from "../../utils/logger.js";
|
||||
import { parseWebUiConfig } from "../../database/routes/host-web-endpoints.js";
|
||||
import {
|
||||
activeTunnelRuntimes,
|
||||
cleanupTunnelResources,
|
||||
connectSSHTunnel,
|
||||
connectionStatus,
|
||||
tunnelConnecting,
|
||||
} from "./manager.js";
|
||||
import { forwardOut } from "./ssh-primitives.js";
|
||||
import { buildWebEndpointTunnelName } from "./utils.js";
|
||||
import type { TunnelConfig, WebEndpoint } from "../../../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("../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 function registerWebEndpointRoutes(app: express.Express): void {
|
||||
// Constructed here 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();
|
||||
// The "/ssh" prefix is part of the path this service serves, exactly as
|
||||
// every route in routes.ts carries it -- nginx proxies /ssh through with the
|
||||
// path intact. Registering "/tunnel/..." here 404s every call, since the
|
||||
// client resolves "/tunnel/web-endpoint/open" against a baseURL that already
|
||||
// ends in /ssh.
|
||||
app.post(
|
||||
"/ssh/tunnel/web-endpoint/open",
|
||||
authenticateJWT,
|
||||
handleWebEndpointOpen,
|
||||
);
|
||||
}
|
||||
@@ -99,6 +99,15 @@
|
||||
* in SDK v1, see worker-bootstrap.ts), so there is currently no way for a
|
||||
* worker-tier plugin to make an outbound HTTP request.
|
||||
*
|
||||
* web-endpoint is here for the same reason ssh-terminal is, on a smaller
|
||||
* scale: its open route polls the tunnel manager's live maps
|
||||
* (activeTunnelRuntimes, connectionStatus, tunnelConnecting), builds a
|
||||
* TunnelConfig from plaintext host credentials that ctx.hosts deliberately
|
||||
* withholds, and hands the resulting live ssh2.Client to forwardOut to probe
|
||||
* the target before reporting success. A structured-clone boundary can carry
|
||||
* none of those three, so this is a genuine transport dependency rather than
|
||||
* the URL-stability concession network-topology and workspaces make.
|
||||
*
|
||||
* workspaces is here for the same reason network-topology is: its routes are
|
||||
* plain per-user JSON CRUD against a single repository, and would fit the
|
||||
* worker ctx fine on their own. It stays in-process to keep its existing
|
||||
@@ -119,6 +128,7 @@ export const FIRST_PARTY_PLUGIN_IDS: ReadonlySet<string> = new Set([
|
||||
"network-topology",
|
||||
"tailscale",
|
||||
"workspaces",
|
||||
"web-endpoint",
|
||||
]);
|
||||
|
||||
export const TRANSPORT_OWNER_CAPABILITY = "process:transport-owner";
|
||||
|
||||
@@ -300,11 +300,15 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
|
||||
// its router with its own dispatcher in database.ts on activate (/ai,
|
||||
// /proxmox, /guacamole, /fleets, /automations, /network-topology,
|
||||
// /workspaces), so disabling any of them makes its routes 404 instead of
|
||||
// leaving a dead import here. Automations' scheduler (due schedules,
|
||||
// leaving a dead import here. Web Endpoint works the same way, except its
|
||||
// dispatcher is mounted by the tunnel service below rather than by
|
||||
// database.ts, since its route is served on port 30003. Automations'
|
||||
// scheduler (due schedules,
|
||||
// dwell rechecks, docker-event polling, history pruning) also starts
|
||||
// from its own activate() rather than here. See plugins/ai,
|
||||
// plugins/proxmox, plugins/remote-desktop, plugins/fleets,
|
||||
// plugins/automations, plugins/network-topology and plugins/workspaces.
|
||||
// plugins/automations, plugins/network-topology, plugins/workspaces and
|
||||
// plugins/web-endpoint.
|
||||
await import("./hosts/tunnel/index.js");
|
||||
await import("./hosts/file-manager/index.js");
|
||||
await import("./hosts/tmux/index.js");
|
||||
|
||||
@@ -415,3 +415,54 @@ describe("bundled workspaces plugin", () => {
|
||||
expect(source).toMatch(/export async function deactivate/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bundled web-endpoint plugin", () => {
|
||||
it("ships a directory with a manifest and both entry points", () => {
|
||||
const dir = path.join(getBundledPluginsDir(), "web-endpoint");
|
||||
|
||||
expect(fs.existsSync(path.join(dir, "manifest.json"))).toBe(true);
|
||||
expect(fs.existsSync(path.join(dir, "backend", "index.mjs"))).toBe(true);
|
||||
expect(fs.existsSync(path.join(dir, "frontend", "index.mjs"))).toBe(true);
|
||||
});
|
||||
|
||||
it("has a manifest that passes the real validator", () => {
|
||||
const { manifest, errors } = parseManifest(
|
||||
readBundledManifest("web-endpoint"),
|
||||
);
|
||||
|
||||
expect(errors).toEqual([]);
|
||||
expect(manifest?.id).toBe("web-endpoint");
|
||||
expect(manifest?.category).toBe("Networking");
|
||||
});
|
||||
|
||||
it("declares the transport-owner capability and qualifies for the tier", () => {
|
||||
const { manifest } = parseManifest(readBundledManifest("web-endpoint"));
|
||||
|
||||
expect(manifest?.permissions).toContain(TRANSPORT_OWNER_CAPABILITY);
|
||||
expect(isFirstParty("web-endpoint")).toBe(true);
|
||||
expect(runsInProcess("web-endpoint", manifest!.permissions)).toBe(true);
|
||||
});
|
||||
|
||||
// The column this points at stays on core's ssh_data table, so a typo here
|
||||
// would silently detach the host-editor checkbox from its storage.
|
||||
it("contributes the enableWebUi host capability", () => {
|
||||
const { manifest } = parseManifest(readBundledManifest("web-endpoint"));
|
||||
|
||||
expect(manifest?.contributes?.hostCapability).toMatchObject({
|
||||
key: "enableWebUi",
|
||||
});
|
||||
});
|
||||
|
||||
it("exports activate and deactivate from its backend entry", () => {
|
||||
const entry = path.join(
|
||||
getBundledPluginsDir(),
|
||||
"web-endpoint",
|
||||
"backend",
|
||||
"index.mjs",
|
||||
);
|
||||
const source = fs.readFileSync(entry, "utf8");
|
||||
|
||||
expect(source).toMatch(/export async function activate/);
|
||||
expect(source).toMatch(/export async function deactivate/);
|
||||
});
|
||||
});
|
||||
@@ -39,7 +39,7 @@ function manifest(overrides: Record<string, unknown> = {}) {
|
||||
}
|
||||
|
||||
describe("first-party allowlist", () => {
|
||||
it("contains only ssh-terminal, docker, host-metrics, ai, proxmox, remote-desktop, fleets, automations, network-topology, tailscale and workspaces", () => {
|
||||
it("contains only ssh-terminal, docker, host-metrics, ai, proxmox, remote-desktop, fleets, automations, network-topology, tailscale, workspaces and web-endpoint", () => {
|
||||
expect([...FIRST_PARTY_PLUGIN_IDS].sort()).toEqual([
|
||||
"ai",
|
||||
"automations",
|
||||
@@ -51,11 +51,12 @@ describe("first-party allowlist", () => {
|
||||
"remote-desktop",
|
||||
"ssh-terminal",
|
||||
"tailscale",
|
||||
"web-endpoint",
|
||||
"workspaces",
|
||||
]);
|
||||
});
|
||||
|
||||
it("recognises ssh-terminal, docker, host-metrics, ai, proxmox, remote-desktop, fleets, automations, network-topology, tailscale and workspaces and nothing else", () => {
|
||||
it("recognises ssh-terminal, docker, host-metrics, ai, proxmox, remote-desktop, fleets, automations, network-topology, tailscale, workspaces and web-endpoint and nothing else", () => {
|
||||
expect(isFirstParty("ssh-terminal")).toBe(true);
|
||||
expect(isFirstParty("docker")).toBe(true);
|
||||
expect(isFirstParty("host-metrics")).toBe(true);
|
||||
@@ -67,6 +68,7 @@ describe("first-party allowlist", () => {
|
||||
expect(isFirstParty("network-topology")).toBe(true);
|
||||
expect(isFirstParty("tailscale")).toBe(true);
|
||||
expect(isFirstParty("workspaces")).toBe(true);
|
||||
expect(isFirstParty("web-endpoint")).toBe(true);
|
||||
expect(isFirstParty("ssh-terminal-pro")).toBe(false);
|
||||
expect(isFirstParty("community-plugin")).toBe(false);
|
||||
expect(isFirstParty("")).toBe(false);
|
||||
@@ -98,6 +100,9 @@ describe("runsInProcess", () => {
|
||||
expect(runsInProcess("workspaces", [TRANSPORT_OWNER_CAPABILITY])).toBe(
|
||||
true,
|
||||
);
|
||||
expect(runsInProcess("web-endpoint", [TRANSPORT_OWNER_CAPABILITY])).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
// On the list but not asking for it: stays in a worker, so the manifest
|
||||
// remains an honest description of what the plugin does.
|
||||
@@ -112,6 +117,7 @@ describe("runsInProcess", () => {
|
||||
expect(runsInProcess("network-topology", ["hosts.read"])).toBe(false);
|
||||
expect(runsInProcess("tailscale", ["hosts.read"])).toBe(false);
|
||||
expect(runsInProcess("workspaces", ["hosts.read"])).toBe(false);
|
||||
expect(runsInProcess("web-endpoint", ["hosts.read"])).toBe(false);
|
||||
|
||||
// Asking for it but not on the list.
|
||||
expect(
|
||||
@@ -251,6 +257,18 @@ describe("manifest gate on the reserved capability", () => {
|
||||
expect(parsed?.id).toBe("workspaces");
|
||||
});
|
||||
|
||||
it("allows it for web-endpoint", () => {
|
||||
const { manifest: parsed, errors } = parseManifest(
|
||||
manifest({
|
||||
id: "web-endpoint",
|
||||
permissions: [TRANSPORT_OWNER_CAPABILITY],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(errors).toEqual([]);
|
||||
expect(parsed?.id).toBe("web-endpoint");
|
||||
});
|
||||
|
||||
it("still accepts an ordinary manifest that never mentions it", () => {
|
||||
const { manifest: parsed, errors } = parseManifest(
|
||||
manifest({ permissions: ["hosts.read"] }),
|
||||
|
||||
+52
-28
@@ -1,8 +1,9 @@
|
||||
import type express from "express";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const resolveHostById = vi.hoisted(() => vi.fn());
|
||||
vi.mock("../../hosts/host-resolver.js", () => ({ resolveHostById }));
|
||||
vi.mock("../../../hosts/host-resolver.js", () => ({ resolveHostById }));
|
||||
|
||||
const manager = vi.hoisted(() => ({
|
||||
activeTunnelRuntimes: new Map<string, unknown>(),
|
||||
@@ -11,12 +12,12 @@ const manager = vi.hoisted(() => ({
|
||||
connectSSHTunnel: vi.fn(),
|
||||
cleanupTunnelResources: vi.fn(async () => undefined),
|
||||
}));
|
||||
vi.mock("../../hosts/tunnel/manager.js", () => manager);
|
||||
vi.mock("../../../hosts/tunnel/manager.js", () => manager);
|
||||
|
||||
const forwardOut = vi.hoisted(() => vi.fn(async () => ({ end: vi.fn() })));
|
||||
vi.mock("../../hosts/tunnel/ssh-primitives.js", () => ({ forwardOut }));
|
||||
vi.mock("../../../hosts/tunnel/ssh-primitives.js", () => ({ forwardOut }));
|
||||
|
||||
vi.mock("../../utils/auth-manager.js", () => ({
|
||||
vi.mock("../../../utils/auth-manager.js", () => ({
|
||||
AuthManager: { getInstance: () => ({ createAuthMiddleware: () => vi.fn() }) },
|
||||
}));
|
||||
|
||||
@@ -102,7 +103,7 @@ describe("POST /tunnel/web-endpoint/open", () => {
|
||||
it("refuses a host the user cannot resolve, without opening anything", async () => {
|
||||
resolveHostById.mockResolvedValue(null);
|
||||
const { handleWebEndpointOpen } =
|
||||
await import("../../hosts/tunnel/web-endpoint-routes.js");
|
||||
await import("../../../../../plugins/web-endpoint/backend/routes.js");
|
||||
const res = response();
|
||||
await handleWebEndpointOpen(request({ hostId: 7, endpointId: "e1" }), res);
|
||||
|
||||
@@ -115,7 +116,7 @@ describe("POST /tunnel/web-endpoint/open", () => {
|
||||
// stored enable_web_ui is still 0. The UI reads as off in that state.
|
||||
resolveHostById.mockResolvedValue(host({ enableWebUi: false }));
|
||||
const { handleWebEndpointOpen } =
|
||||
await import("../../hosts/tunnel/web-endpoint-routes.js");
|
||||
await import("../../../../../plugins/web-endpoint/backend/routes.js");
|
||||
const res = response();
|
||||
await handleWebEndpointOpen(request({ hostId: 7, endpointId: "e1" }), res);
|
||||
|
||||
@@ -125,7 +126,7 @@ describe("POST /tunnel/web-endpoint/open", () => {
|
||||
|
||||
it("refuses an endpoint id that matches nothing", async () => {
|
||||
const { handleWebEndpointOpen } =
|
||||
await import("../../hosts/tunnel/web-endpoint-routes.js");
|
||||
await import("../../../../../plugins/web-endpoint/backend/routes.js");
|
||||
const res = response();
|
||||
await handleWebEndpointOpen(
|
||||
request({ hostId: 7, endpointId: "nope" }),
|
||||
@@ -137,7 +138,7 @@ describe("POST /tunnel/web-endpoint/open", () => {
|
||||
it("returns the real bound port", async () => {
|
||||
tunnelConnectsOn(41234);
|
||||
const { handleWebEndpointOpen } =
|
||||
await import("../../hosts/tunnel/web-endpoint-routes.js");
|
||||
await import("../../../../../plugins/web-endpoint/backend/routes.js");
|
||||
const res = response();
|
||||
await handleWebEndpointOpen(request({ hostId: 7, endpointId: "e1" }), res);
|
||||
|
||||
@@ -151,7 +152,7 @@ describe("POST /tunnel/web-endpoint/open", () => {
|
||||
// select a different strategy and the forward would never bind locally.
|
||||
tunnelConnectsOn(41234);
|
||||
const { handleWebEndpointOpen } =
|
||||
await import("../../hosts/tunnel/web-endpoint-routes.js");
|
||||
await import("../../../../../plugins/web-endpoint/backend/routes.js");
|
||||
await handleWebEndpointOpen(
|
||||
request({ hostId: 7, endpointId: "e1" }),
|
||||
response(),
|
||||
@@ -170,7 +171,7 @@ describe("POST /tunnel/web-endpoint/open", () => {
|
||||
it("binds loopback by default and the endpoint's choice when given", async () => {
|
||||
tunnelConnectsOn(41234);
|
||||
const { handleWebEndpointOpen } =
|
||||
await import("../../hosts/tunnel/web-endpoint-routes.js");
|
||||
await import("../../../../../plugins/web-endpoint/backend/routes.js");
|
||||
await handleWebEndpointOpen(
|
||||
request({ hostId: 7, endpointId: "e1" }),
|
||||
response(),
|
||||
@@ -199,7 +200,7 @@ describe("POST /tunnel/web-endpoint/open", () => {
|
||||
it("reuses a live tunnel instead of opening a second one", async () => {
|
||||
tunnelConnectsOn(41234);
|
||||
const { handleWebEndpointOpen } =
|
||||
await import("../../hosts/tunnel/web-endpoint-routes.js");
|
||||
await import("../../../../../plugins/web-endpoint/backend/routes.js");
|
||||
const first = response();
|
||||
await handleWebEndpointOpen(
|
||||
request({ hostId: 7, endpointId: "e1" }),
|
||||
@@ -218,7 +219,7 @@ describe("POST /tunnel/web-endpoint/open", () => {
|
||||
it("reopens when the endpoint's target changed under a live tunnel", async () => {
|
||||
tunnelConnectsOn(41234);
|
||||
const { handleWebEndpointOpen } =
|
||||
await import("../../hosts/tunnel/web-endpoint-routes.js");
|
||||
await import("../../../../../plugins/web-endpoint/backend/routes.js");
|
||||
await handleWebEndpointOpen(
|
||||
request({ hostId: 7, endpointId: "e1" }),
|
||||
response(),
|
||||
@@ -250,7 +251,7 @@ describe("POST /tunnel/web-endpoint/open", () => {
|
||||
new Error("Channel open failure: connect failed"),
|
||||
);
|
||||
const { handleWebEndpointOpen } =
|
||||
await import("../../hosts/tunnel/web-endpoint-routes.js");
|
||||
await import("../../../../../plugins/web-endpoint/backend/routes.js");
|
||||
const res = response();
|
||||
await handleWebEndpointOpen(request({ hostId: 7, endpointId: "e1" }), res);
|
||||
|
||||
@@ -275,7 +276,7 @@ describe("POST /tunnel/web-endpoint/open", () => {
|
||||
},
|
||||
);
|
||||
const { handleWebEndpointOpen } =
|
||||
await import("../../hosts/tunnel/web-endpoint-routes.js");
|
||||
await import("../../../../../plugins/web-endpoint/backend/routes.js");
|
||||
const res = response();
|
||||
await handleWebEndpointOpen(request({ hostId: 7, endpointId: "e1" }), res);
|
||||
|
||||
@@ -285,7 +286,7 @@ describe("POST /tunnel/web-endpoint/open", () => {
|
||||
|
||||
it("rejects a malformed request before touching the database", async () => {
|
||||
const { handleWebEndpointOpen } =
|
||||
await import("../../hosts/tunnel/web-endpoint-routes.js");
|
||||
await import("../../../../../plugins/web-endpoint/backend/routes.js");
|
||||
for (const body of [
|
||||
{},
|
||||
{ hostId: "7", endpointId: "e1" },
|
||||
@@ -302,25 +303,48 @@ describe("POST /tunnel/web-endpoint/open", () => {
|
||||
/**
|
||||
* The client resolves "/tunnel/web-endpoint/open" against a baseURL that
|
||||
* already ends in /ssh, and nginx proxies /ssh through with the path intact --
|
||||
* so the server must register the FULL "/ssh/tunnel/..." path, as every route
|
||||
* in routes.ts does. Registering the unprefixed form 404s every call, and no
|
||||
* handler unit test notices because they call the handler directly.
|
||||
* so the server must serve the FULL "/ssh/tunnel/web-endpoint/open" path.
|
||||
* Serving the unprefixed form 404s every call, and no handler unit test
|
||||
* notices because they call the handler directly.
|
||||
*
|
||||
* This was caught only by opening a real tunnel against a deployed build.
|
||||
*
|
||||
* Since the plugin migration that path is composed of two halves: the mount
|
||||
* point in src/backend/hosts/tunnel/index.ts and the route on the plugin's own
|
||||
* router. Either half drifting breaks the URL, so both are asserted here.
|
||||
*/
|
||||
describe("route registration", () => {
|
||||
it("registers the /ssh-prefixed path the client actually calls", async () => {
|
||||
const posts: string[] = [];
|
||||
const app = {
|
||||
post: (path: string) => {
|
||||
posts.push(path);
|
||||
it("registers /open on the router the dispatcher forwards to", async () => {
|
||||
const routes =
|
||||
await import("../../../../../plugins/web-endpoint/backend/routes.js");
|
||||
const dispatch =
|
||||
await import("../../../hosts/tunnel/web-endpoint-dispatch.js");
|
||||
const registered: unknown[] = [];
|
||||
vi.spyOn(dispatch, "registerWebEndpointRouter").mockImplementation(
|
||||
(router) => {
|
||||
registered.push(router);
|
||||
},
|
||||
} as unknown as express.Express;
|
||||
);
|
||||
|
||||
const { registerWebEndpointRoutes } =
|
||||
await import("../../hosts/tunnel/web-endpoint-routes.js");
|
||||
registerWebEndpointRoutes(app);
|
||||
routes.startWebEndpointService();
|
||||
|
||||
expect(posts).toContain("/ssh/tunnel/web-endpoint/open");
|
||||
expect(registered).toHaveLength(1);
|
||||
const paths = (
|
||||
routes.router as unknown as {
|
||||
stack: Array<{ route?: { path: string; methods: { post?: boolean } } }>;
|
||||
}
|
||||
).stack
|
||||
.filter((layer) => layer.route?.methods.post)
|
||||
.map((layer) => layer.route?.path);
|
||||
expect(paths).toContain("/open");
|
||||
});
|
||||
|
||||
it("is mounted so the full client path resolves", async () => {
|
||||
const source = await readFile(
|
||||
new URL("../../../hosts/tunnel/index.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
expect(source).toContain('app.use("/ssh/tunnel/web-endpoint"');
|
||||
});
|
||||
});
|
||||
@@ -1,139 +0,0 @@
|
||||
import {
|
||||
currentTunnelHost,
|
||||
resolveWebEndpointUrl,
|
||||
} from "@/lib/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");
|
||||
}
|
||||
@@ -1,227 +0,0 @@
|
||||
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 "@/lib/web-endpoint-url";
|
||||
import {
|
||||
allowInvalidCertificateForOrigin,
|
||||
openWebEndpointExternally,
|
||||
openWebEndpointTunnel,
|
||||
requireNumericHostId,
|
||||
} from "@/api/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>
|
||||
);
|
||||
}
|
||||
@@ -1,211 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -44,6 +44,7 @@ const BUILT_IN_TABS_BY_PLUGIN: Record<string, string[]> = {
|
||||
ai: ["ai"],
|
||||
fleets: ["fleet-inventory"],
|
||||
"network-topology": ["network_graph"],
|
||||
"web-endpoint": ["web-endpoint"],
|
||||
};
|
||||
|
||||
let pluginEnabled = new Map<string, boolean>();
|
||||
|
||||
@@ -89,7 +89,7 @@ const loadDockerManager = () =>
|
||||
}));
|
||||
const DockerManager = lazy(loadDockerManager);
|
||||
const loadWebEndpointTab = () =>
|
||||
import("@/features/web-endpoint/WebEndpointTab").then((m) => ({
|
||||
import("../../../plugins/web-endpoint/frontend/WebEndpointTab").then((m) => ({
|
||||
default: m.WebEndpointTab,
|
||||
}));
|
||||
const WebEndpointTab = lazy(loadWebEndpointTab);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { isElectron } from "@/lib/electron";
|
||||
import {
|
||||
webEndpointRefusalReason,
|
||||
type WebEndpointRefusalReason,
|
||||
} from "@/lib/web-endpoint-url";
|
||||
} from "../../../plugins/web-endpoint/frontend/web-endpoint-url";
|
||||
import { Button } from "@/components/button";
|
||||
import { Input } from "@/components/input";
|
||||
import { Checkbox } from "@/components/checkbox";
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
isWebEndpointPortValid,
|
||||
webEndpointErrorKey,
|
||||
webEndpointRowError,
|
||||
} from "@/lib/web-endpoint-validation";
|
||||
} from "../../../plugins/web-endpoint/frontend/web-endpoint-validation";
|
||||
|
||||
/**
|
||||
* crypto.randomUUID is undefined outside a secure context, so a plain-http web
|
||||
|
||||
@@ -87,7 +87,7 @@ import {
|
||||
recordHostActionPreference,
|
||||
} from "@/lib/local-adaptive-preferences";
|
||||
import type { WebEndpoint } from "@/types/index";
|
||||
import { openWebEndpointExternally } from "@/api/web-endpoint-api";
|
||||
import { openWebEndpointExternally } from "../../../../../plugins/web-endpoint/frontend/web-endpoint-api";
|
||||
import { isTabTypeAvailable } from "@/shell/pluginLoader";
|
||||
|
||||
export function statusCheckEnabled(host: Host): boolean {
|
||||
|
||||
@@ -1,162 +0,0 @@
|
||||
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("@/api/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("@/api/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("@/api/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("@/api/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("@/api/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("@/api/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("@/api/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("@/api/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;
|
||||
});
|
||||
});
|
||||
@@ -1,452 +0,0 @@
|
||||
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("@/api/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("@/api/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("@/features/web-endpoint/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("@/features/web-endpoint/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("@/features/web-endpoint/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("@/features/web-endpoint/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("@/features/web-endpoint/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("@/features/web-endpoint/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("@/features/web-endpoint/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("@/features/web-endpoint/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("@/features/web-endpoint/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("@/features/web-endpoint/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("@/features/web-endpoint/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("@/features/web-endpoint/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("@/features/web-endpoint/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("@/features/web-endpoint/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("@/features/web-endpoint/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("@/features/web-endpoint/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("@/features/web-endpoint/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("@/features/web-endpoint/WebEndpointTab");
|
||||
render(<WebEndpointTab host={host({}, [endpoint()])} endpointId="e1" />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTitle("Proxmox")).toHaveAttribute(
|
||||
"src",
|
||||
"https://192.168.1.10:8006/",
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,367 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
currentTunnelHost,
|
||||
resolveWebEndpointUrl,
|
||||
separatedTunnelHost,
|
||||
sharesCookieSiteWithPage,
|
||||
webEndpointRefusalReason,
|
||||
} from "@/lib/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");
|
||||
});
|
||||
});
|
||||
@@ -1,137 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
findInvalidWebEndpoint,
|
||||
isWebEndpointPathValid,
|
||||
isWebEndpointPortValid,
|
||||
webEndpointErrorKey,
|
||||
webEndpointRowError,
|
||||
} from "@/lib/web-endpoint-validation";
|
||||
import { normalizeWebEndpoints } from "../../../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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user