mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
feat: finish the web-endpoint plugin, add ctx.desktop (B8)
This commit is contained in:
1 parent
1682861853
commit
535126fd1b
50 files changed
+2018
-898
No files matched your search
@@ -14,7 +14,12 @@
|
||||
"termix": ">=2.9.0",
|
||||
"api": "1"
|
||||
},
|
||||
"capabilities": ["hosts:read", "network:serve", "ui:surface"],
|
||||
"capabilities": [
|
||||
"hosts:read",
|
||||
"network:serve",
|
||||
"ui:surface",
|
||||
"desktop:window"
|
||||
],
|
||||
"dependencies": {
|
||||
"tunnels": "^1.0.0"
|
||||
},
|
||||
@@ -33,6 +38,20 @@
|
||||
"openFrom": ["host-context-menu"]
|
||||
}
|
||||
],
|
||||
"settings": {
|
||||
"host": {
|
||||
"enableKey": "enableWebUi",
|
||||
"enableLabelKey": "hosts.enableWebUi",
|
||||
"fields": [
|
||||
{
|
||||
"key": "webUiConfig",
|
||||
"type": "json",
|
||||
"labelKey": "hosts.webUiEndpointsSection",
|
||||
"requires": "enableWebUi"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"hostCapability": {
|
||||
"key": "enableWebUi",
|
||||
"labelKey": "hosts.tabWebUi",
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { serializeWebUiConfig } from "../shared/web-endpoint-config.js";
|
||||
|
||||
/**
|
||||
* Registered as ctx.registry.provide("web-endpoint.hostImportNormalizer", ...)
|
||||
* so host-bulk-routes.ts's Termix-JSON import path can validate this
|
||||
* plugin's fields without importing anything from the plugin. Matches the
|
||||
* shape core's PluginHostImportNormalizer type expects.
|
||||
*/
|
||||
export function hostImportNormalizer(
|
||||
raw: Record<string, unknown>,
|
||||
): Record<string, unknown> | null {
|
||||
if (raw.enableWebUi === undefined && raw.webUiConfig === undefined) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
enableWebUi: !!raw.enableWebUi,
|
||||
webUiConfig: raw.webUiConfig ? serializeWebUiConfig(raw.webUiConfig) : null,
|
||||
};
|
||||
}
|
||||
@@ -1,12 +1,25 @@
|
||||
import type { Router } from "express";
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import { startWebEndpointService, stopWebEndpointService } from "./routes.js";
|
||||
import { createWebEndpointRoutes } from "./routes.js";
|
||||
import { hostImportNormalizer } from "./host-import.js";
|
||||
|
||||
export async function activate(ctx: PluginContext) {
|
||||
startWebEndpointService(ctx.http.router(), ctx);
|
||||
ctx.disposables.add(() => stopWebEndpointService());
|
||||
const router = ctx.http.router<Router>();
|
||||
router.use(createWebEndpointRoutes(ctx));
|
||||
|
||||
ctx.registry.provide(
|
||||
"web-endpoint.hostImportNormalizer",
|
||||
hostImportNormalizer,
|
||||
);
|
||||
ctx.disposables.add(
|
||||
() =>
|
||||
void ctx.registry.revoke(
|
||||
"web-endpoint.hostImportNormalizer",
|
||||
hostImportNormalizer,
|
||||
),
|
||||
);
|
||||
|
||||
ctx.log.info("Web Endpoint routes mounted at /plugin-api/web-endpoint");
|
||||
}
|
||||
|
||||
export async function deactivate() {
|
||||
stopWebEndpointService();
|
||||
}
|
||||
export async function deactivate() {}
|
||||
@@ -1,6 +1,9 @@
|
||||
import express, { type Router } from "express";
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import { parseWebUiConfig } from "../../../../src/backend/database/routes/host-web-endpoints.js";
|
||||
import {
|
||||
parseWebUiConfig,
|
||||
type WebEndpoint,
|
||||
} from "../shared/web-endpoint-config.js";
|
||||
|
||||
/** Matches the spec's ten minutes. */
|
||||
const WEB_ENDPOINT_IDLE_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
@@ -31,83 +34,76 @@ export function webEndpointTunnelName(
|
||||
return `web:${hostId}:${endpointId}`;
|
||||
}
|
||||
|
||||
let current: PluginContext | null = null;
|
||||
|
||||
export async function handleWebEndpointOpen(
|
||||
req: express.Request,
|
||||
res: express.Response,
|
||||
): Promise<express.Response | void> {
|
||||
const ctx = current;
|
||||
const userId = ctx?.currentActor();
|
||||
if (!ctx || !userId) {
|
||||
return res.status(401).json({ error: "Authentication required" });
|
||||
}
|
||||
|
||||
const { hostId, endpointId } = req.body ?? {};
|
||||
if (
|
||||
!Number.isInteger(hostId) ||
|
||||
hostId < 1 ||
|
||||
typeof endpointId !== "string"
|
||||
) {
|
||||
return res.status(400).json({ error: "Invalid web endpoint request" });
|
||||
}
|
||||
|
||||
// Deliberately NOT gated to the desktop. The forward binds wherever this
|
||||
// backend runs, and the endpoint's own bindHost decides whether that is
|
||||
// reachable from the browser.
|
||||
|
||||
const { resolveHostById } =
|
||||
await import("../../../../src/backend/hosts/host-resolver.js");
|
||||
const host = await resolveHostById(hostId, userId);
|
||||
async function loadEndpoint(
|
||||
ctx: PluginContext,
|
||||
hostId: number,
|
||||
endpointId: string,
|
||||
): Promise<
|
||||
{ error: { status: number; message: string } } | { endpoint: WebEndpoint }
|
||||
> {
|
||||
const host = await ctx.hosts.get(hostId);
|
||||
if (!host) {
|
||||
return res.status(403).json({ error: "Host not found or access denied" });
|
||||
return {
|
||||
error: { status: 403, message: "Host not found or access denied" },
|
||||
};
|
||||
}
|
||||
|
||||
// A bulk update that sends only { webUiConfig } can leave the endpoint in
|
||||
// the config while the feature is off. The UI reads as off in that state,
|
||||
// so the endpoint being listed is not on its own a licence to open a tunnel.
|
||||
if (!host.enableWebUi) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Web endpoints are not enabled for this host" });
|
||||
// A bulk update that only flips enableWebUi off can leave stale endpoints
|
||||
// in webUiConfig. The UI reads as off in that state, so a listed endpoint
|
||||
// is not on its own a licence to open a tunnel.
|
||||
const enabled = await ctx.settings.getHost<boolean>(hostId, "enableWebUi");
|
||||
if (!enabled) {
|
||||
return {
|
||||
error: {
|
||||
status: 400,
|
||||
message: "Web endpoints are not enabled for this host",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Re-normalized rather than trusted: the stored value predates any later
|
||||
// tightening of the rules, and this is the value a forward is built from.
|
||||
const endpoint = parseWebUiConfig(host.webUiConfig).endpoints.find(
|
||||
// tightening of the rules, and this is the value a forward or a window is
|
||||
// built from.
|
||||
const rawConfig = await ctx.settings.getHost(hostId, "webUiConfig");
|
||||
const endpoint = parseWebUiConfig(rawConfig).endpoints.find(
|
||||
(candidate) => candidate.id === endpointId,
|
||||
);
|
||||
if (!endpoint) {
|
||||
return res.status(400).json({ error: "Web endpoint not found" });
|
||||
}
|
||||
if (endpoint.access !== "tunnel") {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "This endpoint does not use a tunnel" });
|
||||
return { error: { status: 400, message: "Web endpoint not found" } };
|
||||
}
|
||||
|
||||
return { endpoint };
|
||||
}
|
||||
|
||||
async function openTunnel(
|
||||
ctx: PluginContext,
|
||||
hostId: number,
|
||||
endpointId: string,
|
||||
endpoint: WebEndpoint,
|
||||
): Promise<{ bindHost: string; bindPort: number }> {
|
||||
const userId = ctx.currentActor();
|
||||
let tunnels: TunnelsAccess;
|
||||
try {
|
||||
tunnels = ctx.services.get<TunnelsAccess>("tunnels.access", { userId });
|
||||
} catch {
|
||||
return res
|
||||
.status(503)
|
||||
.json({ error: "The tunnels plugin is not available" });
|
||||
throw Object.assign(new Error("The tunnels plugin is not available"), {
|
||||
status: 503,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const handle = await tunnels.forward(
|
||||
return await tunnels.forward(
|
||||
hostId,
|
||||
{
|
||||
targetHost: "127.0.0.1",
|
||||
targetPort: endpoint.port,
|
||||
// Loopback unless the endpoint asks otherwise. A non-loopback bind
|
||||
// publishes the target's web UI to anyone who can reach the port, with
|
||||
// no authentication in front of it, and is only ever an explicit
|
||||
// per-endpoint choice.
|
||||
// publishes the target's web UI to anyone who can reach the port,
|
||||
// with no authentication in front of it, and is only ever an
|
||||
// explicit per-endpoint choice.
|
||||
bindHost: endpoint.bindHost || "127.0.0.1",
|
||||
// A fixed port when the endpoint names one, since a container can only
|
||||
// publish ports it knows in advance. Otherwise the kernel picks.
|
||||
// A fixed port when the endpoint names one, since a container can
|
||||
// only publish ports it knows in advance. Otherwise the kernel picks.
|
||||
bindPort: endpoint.localPort ?? undefined,
|
||||
},
|
||||
{
|
||||
@@ -115,33 +111,141 @@ export async function handleWebEndpointOpen(
|
||||
idleTimeoutMs: WEB_ENDPOINT_IDLE_TIMEOUT_MS,
|
||||
},
|
||||
);
|
||||
return res.status(200).json({ port: handle.bindPort });
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
ctx.log.error(
|
||||
`Failed to open web endpoint tunnel for host ${hostId}: ${reason}`,
|
||||
error instanceof Error ? error : undefined,
|
||||
);
|
||||
return res.status(502).json({ error: reason });
|
||||
throw Object.assign(new Error(reason), { status: 502 });
|
||||
}
|
||||
}
|
||||
|
||||
export const router = express.Router();
|
||||
/** A bare IPv6 literal has to be bracketed to be a legal URL authority. */
|
||||
function bracketIfIpv6(host: string): string {
|
||||
return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
|
||||
}
|
||||
|
||||
let started = false;
|
||||
|
||||
export function startWebEndpointService(
|
||||
mountOn: Router,
|
||||
ctx: PluginContext,
|
||||
): void {
|
||||
current = ctx;
|
||||
if (!started) {
|
||||
router.post("/open", handleWebEndpointOpen);
|
||||
started = true;
|
||||
function endpointUrl(
|
||||
hostAddress: string,
|
||||
endpoint: WebEndpoint,
|
||||
localPort?: number,
|
||||
): string {
|
||||
const path = endpoint.path && endpoint.path.length > 0 ? endpoint.path : "/";
|
||||
if (endpoint.access === "tunnel") {
|
||||
// Electron's isolated window always dials the backend's own loopback,
|
||||
// exactly like the renderer's own currentTunnelHost(true) does.
|
||||
return `${endpoint.scheme}://127.0.0.1:${localPort}${path}`;
|
||||
}
|
||||
mountOn.use(router);
|
||||
return `${endpoint.scheme}://${bracketIfIpv6(hostAddress)}:${endpoint.port}${path}`;
|
||||
}
|
||||
|
||||
export function stopWebEndpointService(): void {
|
||||
current = null;
|
||||
export function createWebEndpointRoutes(ctx: PluginContext): Router {
|
||||
const router = express.Router();
|
||||
|
||||
router.post("/open", async (req, res) => {
|
||||
const userId = ctx.currentActor();
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: "Authentication required" });
|
||||
}
|
||||
|
||||
const { hostId, endpointId } = req.body ?? {};
|
||||
if (
|
||||
!Number.isInteger(hostId) ||
|
||||
hostId < 1 ||
|
||||
typeof endpointId !== "string"
|
||||
) {
|
||||
return res.status(400).json({ error: "Invalid web endpoint request" });
|
||||
}
|
||||
|
||||
// Deliberately NOT gated to the desktop. The forward binds wherever this
|
||||
// backend runs, and the endpoint's own bindHost decides whether that is
|
||||
// reachable from the browser.
|
||||
const resolved = await loadEndpoint(ctx, hostId, endpointId);
|
||||
if ("error" in resolved) {
|
||||
return res
|
||||
.status(resolved.error.status)
|
||||
.json({ error: resolved.error.message });
|
||||
}
|
||||
if (resolved.endpoint.access !== "tunnel") {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "This endpoint does not use a tunnel" });
|
||||
}
|
||||
|
||||
try {
|
||||
const handle = await openTunnel(
|
||||
ctx,
|
||||
hostId,
|
||||
endpointId,
|
||||
resolved.endpoint,
|
||||
);
|
||||
return res.status(200).json({ port: handle.bindPort });
|
||||
} catch (error) {
|
||||
const status = (error as { status?: number }).status ?? 500;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return res.status(status).json({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Opens the endpoint in an isolated Electron window instead of a tab.
|
||||
* Every part of the target URL is resolved server-side (the host's own
|
||||
* declared address for a direct endpoint, or the tunnel port this route
|
||||
* just opened for a tunnel one) so ctx.desktop.openIsolatedWindow's
|
||||
* capability check and audit line cover the whole decision, not just the
|
||||
* final "open a window" step.
|
||||
*/
|
||||
router.post("/open-window", async (req, res) => {
|
||||
const userId = ctx.currentActor();
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: "Authentication required" });
|
||||
}
|
||||
|
||||
const { hostId, endpointId, ignoreCert } = req.body ?? {};
|
||||
if (
|
||||
!Number.isInteger(hostId) ||
|
||||
hostId < 1 ||
|
||||
typeof endpointId !== "string"
|
||||
) {
|
||||
return res.status(400).json({ error: "Invalid web endpoint request" });
|
||||
}
|
||||
|
||||
const resolved = await loadEndpoint(ctx, hostId, endpointId);
|
||||
if ("error" in resolved) {
|
||||
return res
|
||||
.status(resolved.error.status)
|
||||
.json({ error: resolved.error.message });
|
||||
}
|
||||
const { endpoint } = resolved;
|
||||
|
||||
try {
|
||||
let localPort: number | undefined;
|
||||
if (endpoint.access === "tunnel") {
|
||||
const handle = await openTunnel(ctx, hostId, endpointId, endpoint);
|
||||
localPort = handle.bindPort;
|
||||
}
|
||||
|
||||
const host = await ctx.hosts.get(hostId);
|
||||
if (!host) {
|
||||
return res
|
||||
.status(403)
|
||||
.json({ error: "Host not found or access denied" });
|
||||
}
|
||||
|
||||
const url = endpointUrl(host.ip, endpoint, localPort);
|
||||
const result = await ctx.desktop.openIsolatedWindow({
|
||||
url,
|
||||
title: endpoint.label,
|
||||
ignoreCert: endpoint.access === "direct" && ignoreCert === true,
|
||||
});
|
||||
return res.status(200).json(result);
|
||||
} catch (error) {
|
||||
const status = (error as { status?: number }).status ?? 502;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return res.status(status).json({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -1,27 +1,33 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Globe } from "lucide-react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { isElectron } from "@/lib/electron";
|
||||
import {
|
||||
webEndpointRefusalReason,
|
||||
type WebEndpointRefusalReason,
|
||||
} from "./web-endpoint-url";
|
||||
import { Button } from "@/components/button";
|
||||
import { Input } from "@/components/input";
|
||||
import { Checkbox } from "@/components/checkbox";
|
||||
useTranslation,
|
||||
type HostEditorSectionProps,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
FakeSwitch,
|
||||
Input,
|
||||
SectionCard,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/select";
|
||||
import { SectionCard, SettingRow, FakeSwitch } from "@/components/section-card";
|
||||
SettingRow,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import { resolveConnectionOrigin } from "@/lib/connection-origin";
|
||||
import { isElectron } from "@/lib/electron";
|
||||
import {
|
||||
MAX_WEB_ENDPOINTS,
|
||||
MAX_WEB_ENDPOINT_LABEL_LENGTH,
|
||||
type WebEndpoint,
|
||||
type WebUiConfig,
|
||||
} from "@/types/index";
|
||||
} from "../shared/web-endpoint-config";
|
||||
import {
|
||||
webEndpointRefusalReason,
|
||||
type WebEndpointRefusalReason,
|
||||
} from "./web-endpoint-url";
|
||||
import {
|
||||
MAX_WEB_ENDPOINT_PORT,
|
||||
MIN_WEB_ENDPOINT_PORT,
|
||||
@@ -30,11 +36,12 @@ import {
|
||||
webEndpointRowError,
|
||||
} from "./web-endpoint-validation";
|
||||
|
||||
type PluginSettingsForm = Record<string, Record<string, unknown>>;
|
||||
|
||||
/**
|
||||
* crypto.randomUUID is undefined outside a secure context, so a plain-http web
|
||||
* deployment -- a first-class target for the direct+external path -- would
|
||||
* throw on "Add endpoint". Matches the guarded form already used in
|
||||
* MacrosPanel, PanePreview, KeybindingsDialog and AppShell.
|
||||
* throw on "Add endpoint".
|
||||
*/
|
||||
function endpointId(): string {
|
||||
return typeof crypto.randomUUID === "function"
|
||||
@@ -79,22 +86,74 @@ function newEndpoint(label: string): WebEndpoint {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The host editor's Web UI tab. Writes into the form's pluginSettings for
|
||||
* this plugin, which the editor saves through the plugin's host settings
|
||||
* route after the host itself, exactly as the tunnels plugin's own section
|
||||
* does.
|
||||
*
|
||||
* Owns the async connection-origin resolution for the tunnel gate itself
|
||||
* (tunnelAvailable = enableSsh && originIsLocal), deliberately NOT gated on
|
||||
* isElectron(): the forward binds wherever the backend runs, exactly as the
|
||||
* tunnels plugin does, and a web deployment reaches it at the host serving
|
||||
* Termix provided the endpoint opted out of a loopback bind.
|
||||
*
|
||||
* connectionType is passed as "ssh" deliberately: a tunnel endpoint always
|
||||
* rides an SSH connection, and "ssh" keeps resolveConnectionOrigin out of its
|
||||
* RDP/VNC/Telnet guacamole special case, which always resolves to "remote".
|
||||
*
|
||||
* Note the deliberate asymmetry with the sidebar: the sidebar entry appears on
|
||||
* enableWebUi alone, because a direct endpoint needs no SSH -- but Web UI is
|
||||
* an SSH sub-tab, so a host with SSH disabled cannot configure endpoints at
|
||||
* all. That is the accepted behaviour, not an oversight.
|
||||
*/
|
||||
export function HostEditorWebUiSection({
|
||||
enableWebUi,
|
||||
webUiConfig,
|
||||
tunnelAvailable,
|
||||
setField,
|
||||
}: {
|
||||
enableWebUi: boolean;
|
||||
webUiConfig: WebUiConfig;
|
||||
tunnelAvailable: boolean;
|
||||
setField: (field: string, value: unknown) => void;
|
||||
}) {
|
||||
form,
|
||||
updateForm,
|
||||
protocols,
|
||||
}: HostEditorSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const endpoints = webUiConfig?.endpoints ?? [];
|
||||
const [originIsLocal, setOriginIsLocal] = useState(false);
|
||||
|
||||
const connectionOrigin = (form?.connectionOrigin as string | null) ?? null;
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void resolveConnectionOrigin({
|
||||
connectionType: "ssh",
|
||||
connectionOrigin: connectionOrigin as "local" | "remote" | null,
|
||||
}).then((origin) => {
|
||||
if (!cancelled) setOriginIsLocal(origin === "local");
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [connectionOrigin]);
|
||||
|
||||
const tunnelAvailable = protocols.enableSsh && originIsLocal;
|
||||
|
||||
const settings = ((form?.pluginSettings as PluginSettingsForm | undefined)?.[
|
||||
"web-endpoint"
|
||||
] ?? {}) as Record<string, unknown>;
|
||||
const enableWebUi = settings.enableWebUi === true;
|
||||
const webUiConfig = (settings.webUiConfig ?? { endpoints: [] }) as {
|
||||
endpoints: WebEndpoint[];
|
||||
};
|
||||
const endpoints = webUiConfig.endpoints ?? [];
|
||||
|
||||
const setSettings = (patch: Record<string, unknown>) =>
|
||||
updateForm((current) => {
|
||||
const all = (current.pluginSettings ?? {}) as PluginSettingsForm;
|
||||
return {
|
||||
...current,
|
||||
pluginSettings: {
|
||||
...all,
|
||||
"web-endpoint": { ...(all["web-endpoint"] ?? {}), ...patch },
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const commit = (next: WebEndpoint[]) =>
|
||||
setField("webUiConfig", { endpoints: next });
|
||||
setSettings({ webUiConfig: { endpoints: next } });
|
||||
|
||||
const update = (index: number, patch: Partial<WebEndpoint>) =>
|
||||
commit(
|
||||
@@ -135,7 +194,7 @@ export function HostEditorWebUiSection({
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={enableWebUi}
|
||||
onChange={(v: boolean) => setField("enableWebUi", v)}
|
||||
onChange={(v: boolean) => setSettings({ enableWebUi: v })}
|
||||
/>
|
||||
</SettingRow>
|
||||
<div className="text-xs text-muted-foreground p-3 bg-muted/30 border border-border space-y-1">
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { resolveConnectionOrigin } from "@/lib/connection-origin";
|
||||
import type { HostEditorForm, HostProtocols } from "@/sidebar/HostEditorData";
|
||||
import { HostEditorWebUiSection } from "./HostEditorWebUiSection";
|
||||
|
||||
type SetHostField = <K extends keyof HostEditorForm>(
|
||||
key: K,
|
||||
value: HostEditorForm[K],
|
||||
) => void;
|
||||
|
||||
/**
|
||||
* Owns the async connection-origin resolution for the Web UI tab's tunnel
|
||||
* gate, then hands the resolved boolean to the presentational section.
|
||||
*
|
||||
* tunnelAvailable = enableSsh && originIsLocal. Deliberately NOT gated on
|
||||
* isElectron(): the forward binds wherever the backend runs, exactly as the
|
||||
* server tunnels feature does, and a web deployment reaches it at the host
|
||||
* serving Termix provided the endpoint opted out of a loopback bind. Gating
|
||||
* this on the desktop would be stricter than the tunnels feature it mirrors.
|
||||
*
|
||||
* connectionType is passed as "ssh" deliberately: a tunnel endpoint always
|
||||
* rides an SSH connection, and "ssh" keeps resolveConnectionOrigin out of its
|
||||
* RDP/VNC/Telnet guacamole special case, which always resolves to "remote".
|
||||
*
|
||||
* The origin state lives here rather than in HostEditor.tsx, which is already
|
||||
* very long; this follows HostEditorGeneralTab's precedent of taking
|
||||
* `protocols` as a prop and gating its own connection-origin control.
|
||||
*
|
||||
* Note the deliberate asymmetry with the sidebar: the sidebar entry appears on
|
||||
* enableWebUi alone, because a direct endpoint needs no SSH -- but Web UI is
|
||||
* an SSH sub-tab, so a host with SSH disabled cannot configure endpoints at
|
||||
* all. That is the accepted behaviour, not an oversight.
|
||||
*/
|
||||
export function HostWebUiTab({
|
||||
form,
|
||||
setField,
|
||||
protocols,
|
||||
}: {
|
||||
form: HostEditorForm;
|
||||
setField: SetHostField;
|
||||
protocols: HostProtocols;
|
||||
}) {
|
||||
const [originIsLocal, setOriginIsLocal] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void resolveConnectionOrigin({
|
||||
connectionType: "ssh",
|
||||
connectionOrigin: form.connectionOrigin,
|
||||
}).then((origin) => {
|
||||
if (!cancelled) setOriginIsLocal(origin === "local");
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [form.connectionOrigin]);
|
||||
|
||||
return (
|
||||
<HostEditorWebUiSection
|
||||
enableWebUi={form.enableWebUi}
|
||||
webUiConfig={form.webUiConfig}
|
||||
tunnelAvailable={protocols.enableSsh && originIsLocal}
|
||||
setField={setField as (field: string, value: unknown) => void}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import { copyToClipboard } from "@/lib/clipboard";
|
||||
import { isElectron } from "@/lib/electron";
|
||||
import { Button } from "@/components/button";
|
||||
import type { Host } from "@/types/ui-types";
|
||||
import type { WebEndpoint } from "../shared/web-endpoint-config";
|
||||
|
||||
const REFUSAL_MESSAGES: Record<WebEndpointRefusalReason, string> = {
|
||||
"loopback-bind-on-remote-backend": "webEndpoint.tunnelUnreachableFromBrowser",
|
||||
@@ -42,7 +43,10 @@ export function WebEndpointTab({
|
||||
endpointId?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const endpoint = (host.webUiConfig?.endpoints ?? []).find(
|
||||
const webUiConfig = (
|
||||
host.pluginSettings as Record<string, Record<string, unknown>> | undefined
|
||||
)?.["web-endpoint"]?.webUiConfig as { endpoints?: WebEndpoint[] } | undefined;
|
||||
const endpoint = (webUiConfig?.endpoints ?? []).find(
|
||||
(candidate) => candidate.id === endpointId,
|
||||
);
|
||||
|
||||
|
||||
@@ -9,9 +9,9 @@ import type {
|
||||
TermixApp,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import type { Host } from "@/types/ui-types";
|
||||
import type { WebEndpoint } from "@/types";
|
||||
import type { WebEndpoint } from "../shared/web-endpoint-config";
|
||||
import { WebEndpointTab } from "./WebEndpointTab";
|
||||
import { HostWebUiTab } from "./HostWebUiTab";
|
||||
import { HostEditorWebUiSection } from "./HostEditorWebUiSection";
|
||||
import {
|
||||
openWebEndpointExternally,
|
||||
setWebEndpointApi,
|
||||
@@ -19,10 +19,26 @@ import {
|
||||
|
||||
const TAB_TYPE = "web-endpoint";
|
||||
|
||||
function webEndpointSettings(host: PluginHostRecord): {
|
||||
enableWebUi: boolean;
|
||||
webUiConfig: { endpoints: WebEndpoint[] };
|
||||
} {
|
||||
const settings = (
|
||||
host.pluginSettings as Record<string, Record<string, unknown>> | undefined
|
||||
)?.["web-endpoint"];
|
||||
return {
|
||||
enableWebUi: settings?.enableWebUi === true,
|
||||
webUiConfig: (settings?.webUiConfig as
|
||||
{ endpoints: WebEndpoint[] } | undefined) ?? {
|
||||
endpoints: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function endpointsOf(host: PluginHostRecord): WebEndpoint[] {
|
||||
if (!host.enableWebUi) return [];
|
||||
const config = host.webUiConfig as { endpoints?: WebEndpoint[] } | undefined;
|
||||
return config?.endpoints ?? [];
|
||||
const { enableWebUi, webUiConfig } = webEndpointSettings(host);
|
||||
if (!enableWebUi) return [];
|
||||
return webUiConfig.endpoints ?? [];
|
||||
}
|
||||
|
||||
function openEndpoint(
|
||||
@@ -31,8 +47,8 @@ function openEndpoint(
|
||||
shell: ShellApi,
|
||||
): void {
|
||||
if (endpoint.render === "external") {
|
||||
// No tab at all: the real browser opens it. On the desktop the main
|
||||
// process routes it to shell.openExternal.
|
||||
// No tab at all: the real browser opens it. On the desktop the plugin
|
||||
// asks the backend to open it in an isolated window.
|
||||
openWebEndpointExternally(
|
||||
host as unknown as { id: string; ip: string },
|
||||
endpoint,
|
||||
@@ -63,16 +79,8 @@ function EndpointTab({ host, tab }: TabProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function WebUiSection({ form, setField, protocols }: HostEditorSectionProps) {
|
||||
return (
|
||||
<HostWebUiTab
|
||||
form={form}
|
||||
setField={setField as Parameters<typeof HostWebUiTab>[0]["setField"]}
|
||||
protocols={
|
||||
protocols as unknown as Parameters<typeof HostWebUiTab>[0]["protocols"]
|
||||
}
|
||||
/>
|
||||
);
|
||||
function WebUiSection(props: HostEditorSectionProps) {
|
||||
return <HostEditorWebUiSection {...props} />;
|
||||
}
|
||||
|
||||
export function activate(app: TermixApp): void {
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { currentTunnelHost, resolveWebEndpointUrl } from "./web-endpoint-url";
|
||||
import axios from "axios";
|
||||
import type { PluginApiClient } from "@termix/plugin-sdk/frontend";
|
||||
import { handleApiError } from "@/main-axios";
|
||||
import { isElectron } from "@/lib/electron";
|
||||
import type { WebEndpoint } from "@/types/index";
|
||||
import type { WebEndpoint } from "../shared/web-endpoint-config";
|
||||
|
||||
let pluginApi: PluginApiClient | null = null;
|
||||
|
||||
@@ -109,35 +108,43 @@ export function requireNumericHostId(id: string): number {
|
||||
return numericId;
|
||||
}
|
||||
|
||||
/** Desktop windows use a dedicated ephemeral session, including login popups. */
|
||||
/**
|
||||
* Desktop windows use a dedicated ephemeral session, including login popups.
|
||||
*
|
||||
* The backend resolves and validates the target URL (the host's own declared
|
||||
* address for a direct endpoint, or the tunnel port it opens for a tunnel
|
||||
* one) and opens the window itself through ctx.desktop.openIsolatedWindow, so
|
||||
* the capability check and audit line cover the whole decision -- this call
|
||||
* only asks for it and reports whether it worked.
|
||||
*/
|
||||
export async function openWebEndpointExternally(
|
||||
host: { id: string; ip: string },
|
||||
endpoint: WebEndpoint,
|
||||
): Promise<void> {
|
||||
if (!isElectron() || !window.electronAPI?.invoke) {
|
||||
if (!isElectron()) {
|
||||
throw new Error(
|
||||
"Isolated windows require the desktop app. Choose Embedded in the endpoint settings.",
|
||||
);
|
||||
}
|
||||
const localPort =
|
||||
endpoint.access === "tunnel"
|
||||
? await openWebEndpointTunnel(requireNumericHostId(host.id), endpoint.id)
|
||||
: undefined;
|
||||
const url = resolveWebEndpointUrl({
|
||||
hostAddress: host.ip,
|
||||
endpoint,
|
||||
localPort,
|
||||
tunnelHost: currentTunnelHost(true) ?? undefined,
|
||||
});
|
||||
const result = await window.electronAPI.invoke("open-isolated-web-endpoint", {
|
||||
url,
|
||||
ignoreCert: endpoint.ignoreCert === true,
|
||||
});
|
||||
if (
|
||||
!result ||
|
||||
typeof result !== "object" ||
|
||||
!("success" in result) ||
|
||||
result.success !== true
|
||||
)
|
||||
throw new Error("Failed to open isolated web endpoint");
|
||||
if (!pluginApi) throw new Error("The web endpoint plugin is not active");
|
||||
try {
|
||||
await pluginApi.post("/open-window", {
|
||||
hostId: requireNumericHostId(host.id),
|
||||
endpointId: endpoint.id,
|
||||
ignoreCert: endpoint.ignoreCert === true,
|
||||
});
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
const data = error.response?.data as
|
||||
{ error?: unknown; message?: unknown } | undefined;
|
||||
const backendMessage = data?.error ?? data?.message;
|
||||
if (typeof backendMessage === "string" && backendMessage) {
|
||||
throw new WebEndpointTunnelError(
|
||||
backendMessage,
|
||||
error.response?.status,
|
||||
);
|
||||
}
|
||||
}
|
||||
return handleApiError(error, "open isolated web endpoint");
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { WebEndpoint } from "@/types/index";
|
||||
import type { WebEndpoint } from "../shared/web-endpoint-config";
|
||||
|
||||
/**
|
||||
* Builds the URL a web endpoint opens at.
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
import type { WebEndpoint } from "@/types/index";
|
||||
import type { WebEndpoint } from "../shared/web-endpoint-config";
|
||||
|
||||
/**
|
||||
* Editor-side validation for web endpoint rows.
|
||||
*
|
||||
* The authority on what a stored endpoint may look like is
|
||||
* `normalizeWebEndpoints` in
|
||||
* src/backend/database/routes/host-web-endpoints.ts, and it DROPS any row it
|
||||
* refuses rather than reporting it -- so without a check here the user adds a
|
||||
* row, saves, sees no error, and finds the endpoint gone on reload.
|
||||
* `normalizeWebEndpoints` in ../shared/web-endpoint-config.ts, and it DROPS
|
||||
* any row it refuses rather than reporting it -- so without a check here the
|
||||
* user adds a row, saves, sees no error, and finds the endpoint gone on
|
||||
* reload.
|
||||
*
|
||||
* This deliberately does NOT restate that module's full rule set, and does not
|
||||
* import it: the renderer does not import backend route modules (the same
|
||||
* reason MAX_WEB_ENDPOINTS lives in src/types rather than beside the
|
||||
* validator). It covers only the conditions a user can reach by typing.
|
||||
* src/ui/tests/lib/web-endpoint-validation.test.ts runs both implementations
|
||||
* over the same samples so the two cannot drift apart silently.
|
||||
* This deliberately does NOT restate that module's full rule set: it covers
|
||||
* only the conditions a user can reach by typing, with per-field errors a
|
||||
* drop/keep normalizer cannot give. tests/backend/web-endpoint-config.test.ts
|
||||
* covers the normalizer; this file's own tests cover the same inputs here.
|
||||
*/
|
||||
|
||||
export const MIN_WEB_ENDPOINT_PORT = 1;
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* The plugin's own copy of what used to be core's host-web-endpoints.ts,
|
||||
* now that webUiConfig lives in this plugin's host-scope settings instead of
|
||||
* an ssh_data column. Same rules, same shape.
|
||||
*
|
||||
* Shared between backend and frontend (validation runs in both: the backend
|
||||
* is the real enforcement point, the editor mirrors it for instant feedback),
|
||||
* so this file has no runtime dependencies of its own.
|
||||
*/
|
||||
|
||||
export type WebEndpointAccess = "direct" | "tunnel";
|
||||
export type WebEndpointRender = "external" | "embedded";
|
||||
|
||||
/** One web UI a host serves, declared in this plugin's host settings. */
|
||||
export interface WebEndpoint {
|
||||
/**
|
||||
* Stable identifier. Must NOT be derived from the port: it keys both the
|
||||
* tunnel name and the tab identity, so editing a port has to leave a live
|
||||
* tunnel findable under the same name.
|
||||
*/
|
||||
id: string;
|
||||
label: string;
|
||||
scheme: "http" | "https";
|
||||
port: number;
|
||||
/** Defaults to "/". Normalized at the storage boundary, never here. */
|
||||
path?: string;
|
||||
access: WebEndpointAccess;
|
||||
render: WebEndpointRender;
|
||||
/**
|
||||
* Direct endpoints only. Allows an invalid TLS certificate for this
|
||||
* endpoint's exact origin. A no-op for tunnel access, whose host component
|
||||
* is loopback and therefore already exempt.
|
||||
*/
|
||||
ignoreCert?: boolean;
|
||||
/**
|
||||
* Tunnel endpoints only. Where the backend binds the forward, exactly as
|
||||
* the tunnels plugin exposes it. Defaults to 127.0.0.1, reachable only from
|
||||
* the machine running the backend. A web deployment runs the backend on a
|
||||
* server, so reaching the forward from a browser needs an address that
|
||||
* machine answers on -- which also exposes the target's web UI to anyone
|
||||
* who can reach the port, with no login in front of it.
|
||||
*/
|
||||
bindHost?: string;
|
||||
/**
|
||||
* Tunnel endpoints only. Which port the forward listens on, as the tunnels
|
||||
* plugin's Source Port does. Left unset the kernel picks a free one, which
|
||||
* is fine when backend and browser share a machine -- but a container can
|
||||
* only publish ports it knows in advance.
|
||||
*/
|
||||
localPort?: number;
|
||||
}
|
||||
|
||||
export interface WebUiConfig {
|
||||
endpoints: WebEndpoint[];
|
||||
}
|
||||
|
||||
/** A host may declare at most this many web endpoints. */
|
||||
export const MAX_WEB_ENDPOINTS = 16;
|
||||
/** Endpoint labels are truncated to this length. */
|
||||
export const MAX_WEB_ENDPOINT_LABEL_LENGTH = 64;
|
||||
|
||||
const SCHEMES = new Set(["http", "https"]);
|
||||
const ACCESS_VALUES = new Set(["direct", "tunnel"]);
|
||||
const RENDER_VALUES = new Set(["external", "embedded"]);
|
||||
|
||||
const MIN_PORT = 1;
|
||||
const MAX_PORT = 65535;
|
||||
|
||||
function isValidPort(value: unknown): value is number {
|
||||
return (
|
||||
typeof value === "number" &&
|
||||
Number.isInteger(value) &&
|
||||
value >= MIN_PORT &&
|
||||
value <= MAX_PORT
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* C0 controls and DEL. Checked by codepoint rather than by regex character
|
||||
* class so no escaping subtlety can let one through: a path like
|
||||
* "\t//evil.example" defeats a naive startsWith("//") guard, because the
|
||||
* browser strips the control character and then follows the authority.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Coerces a path to begin with "/", or returns null to drop the endpoint.
|
||||
*
|
||||
* This is THE enforcement point for a value that reaches an href and an
|
||||
* iframe src -- the editor's checks are UX. Anything that could redirect the
|
||||
* frame away from the endpoint is rejected rather than sanitized, because
|
||||
* sanitizing invites a second, subtly different implementation elsewhere.
|
||||
*/
|
||||
function normalizePath(raw: unknown): string | null {
|
||||
if (raw === undefined || raw === null || raw === "") return "/";
|
||||
if (typeof raw !== "string") return null;
|
||||
if (hasControlCharacter(raw)) return null;
|
||||
|
||||
const path = raw.startsWith("/") ? raw : `/${raw}`;
|
||||
|
||||
if (path.startsWith("//")) return null;
|
||||
if (/^\/[a-z][a-z0-9+.-]*:/i.test(path)) return null;
|
||||
if (path.includes("\\")) return null;
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
/**
|
||||
* A bare host literal, or null to drop the endpoint, or undefined to leave the
|
||||
* field unset (the route then falls back to loopback).
|
||||
*
|
||||
* The value lands both in a TCP listener and in a URL authority, so nothing
|
||||
* carrying a scheme, port, path or credentials may survive.
|
||||
*/
|
||||
function normalizeBindHost(raw: unknown): string | null | undefined {
|
||||
if (raw === undefined || raw === null || raw === "") return undefined;
|
||||
if (typeof raw !== "string") return null;
|
||||
|
||||
const value = raw.trim();
|
||||
if (!value) return undefined;
|
||||
if (value.length > 253) return null;
|
||||
if (hasControlCharacter(value)) return null;
|
||||
|
||||
const IPV4 = /^\d{1,3}(\.\d{1,3}){3}$/;
|
||||
const HOSTNAME =
|
||||
/^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*$/;
|
||||
const IPV6_BRACKETED = /^\[[0-9A-Fa-f:.]+\]$/;
|
||||
|
||||
if (IPV4.test(value)) {
|
||||
return value.split(".").every((part) => Number(part) <= 255) ? value : null;
|
||||
}
|
||||
if (IPV6_BRACKETED.test(value) || HOSTNAME.test(value)) return value;
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeEndpoint(raw: unknown): WebEndpoint | null {
|
||||
if (!raw || typeof raw !== "object") return null;
|
||||
const input = raw as Record<string, unknown>;
|
||||
|
||||
const id = typeof input.id === "string" ? input.id.trim() : "";
|
||||
if (!id) return null;
|
||||
|
||||
const label = typeof input.label === "string" ? input.label.trim() : "";
|
||||
if (!label) return null;
|
||||
|
||||
const scheme = input.scheme;
|
||||
if (typeof scheme !== "string" || !SCHEMES.has(scheme)) return null;
|
||||
|
||||
if (!isValidPort(input.port)) return null;
|
||||
|
||||
const path = normalizePath(input.path);
|
||||
if (path === null) return null;
|
||||
|
||||
const access = input.access;
|
||||
if (typeof access !== "string" || !ACCESS_VALUES.has(access)) return null;
|
||||
|
||||
const render = input.render;
|
||||
if (typeof render !== "string" || !RENDER_VALUES.has(render)) return null;
|
||||
|
||||
const isTunnel = access === "tunnel";
|
||||
|
||||
const endpoint: WebEndpoint = {
|
||||
id,
|
||||
label: label.slice(0, MAX_WEB_ENDPOINT_LABEL_LENGTH),
|
||||
scheme: scheme as WebEndpoint["scheme"],
|
||||
port: input.port,
|
||||
path,
|
||||
access: access as WebEndpoint["access"],
|
||||
render: render as WebEndpoint["render"],
|
||||
// Meaningless for a tunnel, whose host component is loopback and already
|
||||
// exempt from certificate checks. Cleared rather than carried so nothing
|
||||
// downstream has to re-derive that.
|
||||
ignoreCert: !isTunnel && input.ignoreCert === true,
|
||||
};
|
||||
|
||||
// Both are tunnel-only. Silently ignored on a direct endpoint rather than
|
||||
// rejected: they describe a forward that a direct endpoint never creates.
|
||||
if (isTunnel) {
|
||||
const bindHost = normalizeBindHost(input.bindHost);
|
||||
if (bindHost === null) return null;
|
||||
if (bindHost !== undefined) endpoint.bindHost = bindHost;
|
||||
|
||||
if (input.localPort !== undefined && input.localPort !== null) {
|
||||
if (!isValidPort(input.localPort)) return null;
|
||||
endpoint.localPort = input.localPort;
|
||||
}
|
||||
}
|
||||
|
||||
return endpoint;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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/frontend/web-endpoint-validation.ts).
|
||||
*/
|
||||
export function normalizeWebEndpoints(raw: unknown): WebEndpoint[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
|
||||
const seen = new Set<string>();
|
||||
const endpoints: WebEndpoint[] = [];
|
||||
|
||||
for (const candidate of raw) {
|
||||
if (endpoints.length >= MAX_WEB_ENDPOINTS) break;
|
||||
const endpoint = normalizeEndpoint(candidate);
|
||||
if (!endpoint) continue;
|
||||
if (seen.has(endpoint.id)) continue;
|
||||
seen.add(endpoint.id);
|
||||
endpoints.push(endpoint);
|
||||
}
|
||||
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
/**
|
||||
* Never throws. A malformed stored value yields an empty endpoint list, so a
|
||||
* half-written config cannot take out the whole host listing.
|
||||
*/
|
||||
export function parseWebUiConfig(raw: unknown): WebUiConfig {
|
||||
if (raw === undefined || raw === null) return { endpoints: [] };
|
||||
|
||||
let value: unknown = raw;
|
||||
if (typeof raw === "string") {
|
||||
try {
|
||||
value = JSON.parse(raw);
|
||||
} catch {
|
||||
return { endpoints: [] };
|
||||
}
|
||||
}
|
||||
|
||||
if (!value || typeof value !== "object") return { endpoints: [] };
|
||||
return {
|
||||
endpoints: normalizeWebEndpoints(
|
||||
(value as { endpoints?: unknown }).endpoints,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** Null when nothing survives normalization, so the setting is cleared. */
|
||||
export function serializeWebUiConfig(config: unknown): WebUiConfig | null {
|
||||
const endpoints = normalizeWebEndpoints(
|
||||
(config as { endpoints?: unknown } | null | undefined)?.endpoints,
|
||||
);
|
||||
if (endpoints.length === 0) return null;
|
||||
return { endpoints };
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import express from "express";
|
||||
import {
|
||||
createMockCtx,
|
||||
type MockPluginContext,
|
||||
} from "@termix/plugin-sdk/testing";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import manifestJson from "../../manifest.json";
|
||||
import { activate } from "../../src/backend/index.js";
|
||||
|
||||
const manifest = manifestJson as unknown as PluginManifest;
|
||||
|
||||
let mock: MockPluginContext | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
for (const dispose of [...(mock?.disposals ?? [])].reverse()) await dispose();
|
||||
mock = null;
|
||||
});
|
||||
|
||||
function activateWith(capabilities: string[]) {
|
||||
mock = createMockCtx({
|
||||
pluginId: manifest.id,
|
||||
manifest,
|
||||
capabilities,
|
||||
router: () => express.Router(),
|
||||
});
|
||||
return activate(mock.ctx);
|
||||
}
|
||||
|
||||
describe("web-endpoint activate", () => {
|
||||
it("mounts its router at /plugin-api/web-endpoint", async () => {
|
||||
await activateWith(manifest.capabilities);
|
||||
expect(mock?.httpRouters).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("registers a hostImportNormalizer and revokes it on deactivate", async () => {
|
||||
await activateWith(manifest.capabilities);
|
||||
expect(
|
||||
typeof mock?.ctx.registry.consume("web-endpoint.hostImportNormalizer"),
|
||||
).toBe("function");
|
||||
|
||||
for (const dispose of [...(mock?.disposals ?? [])].reverse())
|
||||
await dispose();
|
||||
expect(
|
||||
mock?.ctx.registry.consume("web-endpoint.hostImportNormalizer"),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("fails closed without network:serve", async () => {
|
||||
await expect(
|
||||
activateWith(manifest.capabilities.filter((c) => c !== "network:serve")),
|
||||
).rejects.toThrow(/network:serve/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { hostImportNormalizer } from "../../src/backend/host-import.js";
|
||||
|
||||
describe("hostImportNormalizer", () => {
|
||||
it("returns null when the row carries neither field", () => {
|
||||
expect(hostImportNormalizer({})).toBeNull();
|
||||
});
|
||||
|
||||
it("normalizes and validates webUiConfig, dropping invalid endpoints", () => {
|
||||
const result = hostImportNormalizer({
|
||||
enableWebUi: true,
|
||||
webUiConfig: {
|
||||
endpoints: [
|
||||
{
|
||||
id: "e1",
|
||||
label: "Proxmox",
|
||||
scheme: "https",
|
||||
port: 8006,
|
||||
access: "direct",
|
||||
render: "embedded",
|
||||
},
|
||||
{
|
||||
id: "e2",
|
||||
label: "",
|
||||
scheme: "https",
|
||||
port: 1,
|
||||
access: "direct",
|
||||
render: "embedded",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(result?.enableWebUi).toBe(true);
|
||||
expect(result?.webUiConfig).toEqual({
|
||||
endpoints: [
|
||||
{
|
||||
id: "e1",
|
||||
label: "Proxmox",
|
||||
scheme: "https",
|
||||
port: 8006,
|
||||
path: "/",
|
||||
access: "direct",
|
||||
render: "embedded",
|
||||
ignoreCert: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("clears webUiConfig to null when enabling with no endpoints", () => {
|
||||
const result = hostImportNormalizer({ enableWebUi: true });
|
||||
expect(result).toEqual({ enableWebUi: true, webUiConfig: null });
|
||||
});
|
||||
|
||||
it("normalizes enableWebUi to a boolean", () => {
|
||||
expect(hostImportNormalizer({ enableWebUi: "yes" })).toEqual({
|
||||
enableWebUi: true,
|
||||
webUiConfig: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,106 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
/**
|
||||
* This codebase enumerates host columns by hand in a lot of places, and
|
||||
* "added a column, missed one update path" is its most repeated bug -- during
|
||||
* the first attempt at this feature a field was missed at one of these points
|
||||
* six separate times, each surfacing as a different mystery (endpoints that
|
||||
* saved but never appeared, config that vanished on reload).
|
||||
*
|
||||
* Every one of these files already enumerates the Docker pair, which is the
|
||||
* exact shape the web endpoint pair copies. So "mentions enableDocker but not
|
||||
* enableWebUi" is a reliable proxy for "was not updated", and it fails loudly
|
||||
* the moment someone adds the tenth enumeration point without this one.
|
||||
*
|
||||
* This is a coarse guard on purpose: it cannot tell whether the field is used
|
||||
* CORRECTLY, only that the file knows it exists. Behavioural coverage lives in
|
||||
* the route tests.
|
||||
*/
|
||||
// Repo-root relative, resolved from this file rather than cwd: the plugin
|
||||
// suite runs with the plugin directory as cwd.
|
||||
const REPO_ROOT = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"../../../..",
|
||||
);
|
||||
|
||||
function source(relative: string): string {
|
||||
return readFileSync(path.resolve(REPO_ROOT, relative), "utf8");
|
||||
}
|
||||
|
||||
const ENUMERATION_POINTS = [
|
||||
"src/backend/database/database.ts",
|
||||
"src/backend/database/db/index.ts",
|
||||
"src/backend/database/routes/host-normalizers.ts",
|
||||
"src/backend/database/routes/host.ts",
|
||||
"src/backend/database/routes/host-bulk-routes.ts",
|
||||
"src/ui/sidebar/host-export-payload.ts",
|
||||
"src/ui/sidebar/HostManagerData.ts",
|
||||
"src/ui/sidebar/HostEditorData.ts",
|
||||
"src/ui/lib/host-to-ssh-host.ts",
|
||||
];
|
||||
|
||||
describe("web endpoint column enumeration", () => {
|
||||
it.each(ENUMERATION_POINTS)(
|
||||
"%s enumerates the enable flag alongside enableDocker",
|
||||
(file) => {
|
||||
const text = source(file);
|
||||
// Guards the guard: if the Docker anchor ever disappears from a file,
|
||||
// this test would otherwise pass vacuously forever.
|
||||
expect(text).toMatch(/enableDocker|enable_docker/);
|
||||
expect(text).toMatch(/enableWebUi|enable_web_ui/);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(ENUMERATION_POINTS)(
|
||||
"%s enumerates the config column alongside dockerConfig",
|
||||
(file) => {
|
||||
const text = source(file);
|
||||
expect(text).toMatch(/dockerConfig|docker_config/);
|
||||
expect(text).toMatch(/webUiConfig|web_ui_config/);
|
||||
},
|
||||
);
|
||||
|
||||
it("parses webUiConfig through the guarded parser, not a bare JSON.parse", () => {
|
||||
// dockerConfig uses a bare JSON.parse, so one malformed value takes out
|
||||
// the whole host listing. Do not copy that.
|
||||
const text = source("src/backend/database/routes/host-normalizers.ts");
|
||||
expect(text).toContain("parseWebUiConfig");
|
||||
expect(text).not.toMatch(/JSON\.parse\(\s*host\.webUiConfig/);
|
||||
});
|
||||
|
||||
it("clears webUiConfig on disable in both host.ts write paths", () => {
|
||||
// Three write paths disagreed on this during the first attempt. The export
|
||||
// payload ships webUiConfig unconditionally, so a host disabled without
|
||||
// clearing still exports its endpoint list -- internal hostnames, ports and
|
||||
// paths -- while the UI reads as off everywhere, and re-enabling silently
|
||||
// resurrects stale endpoints.
|
||||
const text = source("src/backend/database/routes/host.ts");
|
||||
// Create and update. Anchored on the exact conditional, not a loose
|
||||
// pattern: a looser regex here passed even with the guard deleted.
|
||||
const occurrences = text.split("webUiConfig: enableWebUi").length - 1;
|
||||
expect(occurrences).toBe(2);
|
||||
});
|
||||
|
||||
it("clears webUiConfig on a bulk disable", () => {
|
||||
const text = source("src/backend/database/routes/host-bulk-routes.ts");
|
||||
expect(text).toContain(
|
||||
"if (!updates.enableWebUi) simpleUpdates.webUiConfig = null;",
|
||||
);
|
||||
});
|
||||
|
||||
it("shares webUiConfig with connect-level recipients", () => {
|
||||
// A connect-level recipient is ALREADY authorized to open these tunnels
|
||||
// (the open route gates on canAccessHost(..., "connect")), so withholding
|
||||
// the config only breaks discovery while the flag advertises the feature.
|
||||
// The dockerConfig precedent does not transfer: Docker's tab works without
|
||||
// its config, whereas a web endpoint IS its config.
|
||||
const text = source("src/backend/database/routes/host-normalizers.ts");
|
||||
const block = text.slice(text.indexOf("CONNECT_LEVEL_FIELDS"));
|
||||
const list = block.slice(0, block.indexOf("]"));
|
||||
expect(list).toContain("enableWebUi");
|
||||
expect(list).toContain("webUiConfig");
|
||||
});
|
||||
});
|
||||
@@ -1,135 +1,160 @@
|
||||
import http from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import express from "express";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createFakeContext } from "@termix/plugin-sdk/testing";
|
||||
import {
|
||||
createMockCtx,
|
||||
type MockPluginContext,
|
||||
} from "@termix/plugin-sdk/testing";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import manifestJson from "../../manifest.json";
|
||||
import { createWebEndpointRoutes } from "../../src/backend/routes.js";
|
||||
|
||||
const resolveHostById = vi.hoisted(() => vi.fn());
|
||||
vi.mock("../../../../src/backend/hosts/host-resolver.js", () => ({
|
||||
resolveHostById,
|
||||
}));
|
||||
const manifest = manifestJson as unknown as PluginManifest;
|
||||
|
||||
const forward = vi.fn();
|
||||
const openIsolatedWindow = vi.fn();
|
||||
|
||||
function response() {
|
||||
const res = {
|
||||
statusCode: 0,
|
||||
body: undefined as unknown,
|
||||
status(code: number) {
|
||||
this.statusCode = code;
|
||||
return this;
|
||||
},
|
||||
json(payload: unknown) {
|
||||
this.body = payload;
|
||||
return this;
|
||||
},
|
||||
};
|
||||
return res as unknown as express.Response & {
|
||||
statusCode: number;
|
||||
body: { port?: number; error?: string };
|
||||
function endpoint(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "e1",
|
||||
label: "Proxmox",
|
||||
scheme: "http",
|
||||
port: 8006,
|
||||
path: "/",
|
||||
access: "tunnel",
|
||||
render: "embedded",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function request(body: unknown) {
|
||||
return { body } as unknown as express.Request;
|
||||
}
|
||||
|
||||
const endpoint = (overrides: Record<string, unknown> = {}) => ({
|
||||
id: "e1",
|
||||
label: "Proxmox",
|
||||
scheme: "http",
|
||||
port: 8006,
|
||||
path: "/",
|
||||
access: "tunnel",
|
||||
render: "embedded",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
function host(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 7,
|
||||
userId: "u1",
|
||||
name: "nas",
|
||||
ip: "10.0.0.5",
|
||||
port: 22,
|
||||
username: "root",
|
||||
tags: null,
|
||||
folder: null,
|
||||
authType: "password",
|
||||
enableWebUi: true,
|
||||
webUiConfig: JSON.stringify({ endpoints: [endpoint()] }),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** Activates the routes against a fake ctx, with or without the tunnels service. */
|
||||
async function load(options: { tunnels?: boolean; actor?: string } = {}) {
|
||||
const fake = createFakeContext({
|
||||
let mock: MockPluginContext;
|
||||
let server: http.Server;
|
||||
let baseUrl: string;
|
||||
|
||||
async function start(
|
||||
options: {
|
||||
hostOverrides?: Record<string, unknown>;
|
||||
noHost?: boolean;
|
||||
enableWebUi?: boolean;
|
||||
endpoints?: unknown[];
|
||||
capabilities?: string[];
|
||||
tunnelsAvailable?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
mock = createMockCtx({
|
||||
manifest,
|
||||
pluginId: "web-endpoint",
|
||||
actor: options.actor ?? "u1",
|
||||
actor: "u1",
|
||||
capabilities: options.capabilities ?? [
|
||||
"hosts:read",
|
||||
"network:serve",
|
||||
"ui:surface",
|
||||
"desktop:window",
|
||||
],
|
||||
hosts: options.noHost ? [] : [host(options.hostOverrides)],
|
||||
});
|
||||
if (options.tunnels !== false) {
|
||||
fake.ctx.services.provide("tunnels.access", { forward });
|
||||
const hostId = (options.hostOverrides?.id as number) ?? 7;
|
||||
if (options.enableWebUi !== false) {
|
||||
await mock.ctx.settings.setHost(hostId, "enableWebUi", true);
|
||||
await mock.ctx.settings.setHost(hostId, "webUiConfig", {
|
||||
endpoints: options.endpoints ?? [endpoint()],
|
||||
});
|
||||
}
|
||||
const routes = await import("../../src/backend/routes.js");
|
||||
routes.startWebEndpointService(express.Router(), fake.ctx);
|
||||
return routes;
|
||||
if (options.tunnelsAvailable !== false) {
|
||||
mock.ctx.services.provide("tunnels.access", { forward });
|
||||
}
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(createWebEndpointRoutes(mock.ctx));
|
||||
|
||||
server = http.createServer(app);
|
||||
await new Promise<void>((resolve) => server.listen(0, resolve));
|
||||
const { port } = server.address() as AddressInfo;
|
||||
baseUrl = `http://127.0.0.1:${port}`;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
forward.mockReset();
|
||||
forward.mockResolvedValue({ bindHost: "127.0.0.1", bindPort: 41234 });
|
||||
resolveHostById.mockReset();
|
||||
resolveHostById.mockResolvedValue(host());
|
||||
openIsolatedWindow.mockReset();
|
||||
openIsolatedWindow.mockResolvedValue({ success: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetModules();
|
||||
afterEach(async () => {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
});
|
||||
|
||||
describe("POST /open", () => {
|
||||
it("refuses a host the user cannot resolve, without opening anything", async () => {
|
||||
resolveHostById.mockResolvedValue(null);
|
||||
const { handleWebEndpointOpen } = await load();
|
||||
const res = response();
|
||||
await handleWebEndpointOpen(request({ hostId: 7, endpointId: "e1" }), res);
|
||||
|
||||
expect(res.statusCode).toBe(403);
|
||||
await start({ noHost: true });
|
||||
const res = await fetch(`${baseUrl}/open`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ hostId: 7, endpointId: "e1" }),
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
expect(forward).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses when the feature is disabled even though the config still lists the endpoint", async () => {
|
||||
resolveHostById.mockResolvedValue(host({ enableWebUi: false }));
|
||||
const { handleWebEndpointOpen } = await load();
|
||||
const res = response();
|
||||
await handleWebEndpointOpen(request({ hostId: 7, endpointId: "e1" }), res);
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
await start({ enableWebUi: false });
|
||||
await mock.ctx.settings.setHost(7, "webUiConfig", {
|
||||
endpoints: [endpoint()],
|
||||
});
|
||||
const res = await fetch(`${baseUrl}/open`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ hostId: 7, endpointId: "e1" }),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
expect(forward).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses an endpoint id that matches nothing", async () => {
|
||||
const { handleWebEndpointOpen } = await load();
|
||||
const res = response();
|
||||
await handleWebEndpointOpen(
|
||||
request({ hostId: 7, endpointId: "nope" }),
|
||||
res,
|
||||
);
|
||||
expect(res.statusCode).toBe(400);
|
||||
await start();
|
||||
const res = await fetch(`${baseUrl}/open`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ hostId: 7, endpointId: "nope" }),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns the port the tunnels service bound", async () => {
|
||||
const { handleWebEndpointOpen } = await load();
|
||||
const res = response();
|
||||
await handleWebEndpointOpen(request({ hostId: 7, endpointId: "e1" }), res);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body.port).toBe(41234);
|
||||
await start();
|
||||
const res = await fetch(`${baseUrl}/open`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ hostId: 7, endpointId: "e1" }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect((await res.json()).port).toBe(41234);
|
||||
});
|
||||
|
||||
it("asks for a loopback forward under the reserved web name", async () => {
|
||||
const { handleWebEndpointOpen } = await load();
|
||||
await handleWebEndpointOpen(
|
||||
request({ hostId: 7, endpointId: "e1" }),
|
||||
response(),
|
||||
);
|
||||
|
||||
await start();
|
||||
await fetch(`${baseUrl}/open`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ hostId: 7, endpointId: "e1" }),
|
||||
});
|
||||
expect(forward).toHaveBeenCalledWith(
|
||||
7,
|
||||
{
|
||||
@@ -143,85 +168,137 @@ describe("POST /open", () => {
|
||||
});
|
||||
|
||||
it("passes the endpoint's own bind address and fixed port through", async () => {
|
||||
resolveHostById.mockResolvedValue(
|
||||
host({
|
||||
webUiConfig: JSON.stringify({
|
||||
endpoints: [endpoint({ bindHost: "0.0.0.0", localPort: 38080 })],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const { handleWebEndpointOpen } = await load();
|
||||
await handleWebEndpointOpen(
|
||||
request({ hostId: 7, endpointId: "e1" }),
|
||||
response(),
|
||||
);
|
||||
|
||||
await start({
|
||||
endpoints: [endpoint({ bindHost: "0.0.0.0", localPort: 38080 })],
|
||||
});
|
||||
await fetch(`${baseUrl}/open`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ hostId: 7, endpointId: "e1" }),
|
||||
});
|
||||
const target = forward.mock.calls[0][1];
|
||||
expect(target.bindHost).toBe("0.0.0.0");
|
||||
expect(target.bindPort).toBe(38080);
|
||||
});
|
||||
|
||||
it("reports the service's reason as a 502 instead of a dead 200", async () => {
|
||||
await start();
|
||||
forward.mockRejectedValue(
|
||||
new Error("Channel open failure: connect failed"),
|
||||
);
|
||||
const { handleWebEndpointOpen } = await load();
|
||||
const res = response();
|
||||
await handleWebEndpointOpen(request({ hostId: 7, endpointId: "e1" }), res);
|
||||
|
||||
expect(res.statusCode).toBe(502);
|
||||
expect(String(res.body.error)).toMatch(/connect failed/);
|
||||
const res = await fetch(`${baseUrl}/open`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ hostId: 7, endpointId: "e1" }),
|
||||
});
|
||||
expect(res.status).toBe(502);
|
||||
expect(String((await res.json()).error)).toMatch(/connect failed/);
|
||||
});
|
||||
|
||||
it("answers 503 while the tunnels plugin is not available", async () => {
|
||||
const { handleWebEndpointOpen } = await load({ tunnels: false });
|
||||
const res = response();
|
||||
await handleWebEndpointOpen(request({ hostId: 7, endpointId: "e1" }), res);
|
||||
|
||||
expect(res.statusCode).toBe(503);
|
||||
await start({ tunnelsAvailable: false });
|
||||
const res = await fetch(`${baseUrl}/open`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ hostId: 7, endpointId: "e1" }),
|
||||
});
|
||||
expect(res.status).toBe(503);
|
||||
});
|
||||
|
||||
it("rejects a malformed request before touching the database", async () => {
|
||||
const { handleWebEndpointOpen } = await load();
|
||||
it("rejects a malformed request before touching settings", async () => {
|
||||
await start();
|
||||
const getHostSpy = vi.spyOn(mock.ctx.settings, "getHost");
|
||||
for (const body of [
|
||||
{},
|
||||
{ hostId: "7", endpointId: "e1" },
|
||||
{ hostId: 0, endpointId: "e1" },
|
||||
]) {
|
||||
const res = response();
|
||||
await handleWebEndpointOpen(request(body), res);
|
||||
expect(res.statusCode).toBe(400);
|
||||
const res = await fetch(`${baseUrl}/open`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
}
|
||||
expect(resolveHostById).not.toHaveBeenCalled();
|
||||
expect(getHostSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses a direct-access endpoint", async () => {
|
||||
await start({ endpoints: [endpoint({ access: "direct" })] });
|
||||
const res = await fetch(`${baseUrl}/open`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ hostId: 7, endpointId: "e1" }),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The client path and the server path have to agree. Core mounts every
|
||||
* plugin at /plugin-api/<id>, so the client calls /plugin-api/web-endpoint/open
|
||||
* and the only half this plugin owns is "/open".
|
||||
*/
|
||||
describe("route registration", () => {
|
||||
it("registers /open on the router core hands it", async () => {
|
||||
const routes = await load();
|
||||
describe("POST /open-window", () => {
|
||||
it("refuses without desktop:window", async () => {
|
||||
await start({
|
||||
capabilities: ["hosts:read", "network:serve", "ui:surface"],
|
||||
});
|
||||
const res = await fetch(`${baseUrl}/open-window`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ hostId: 7, endpointId: "e1" }),
|
||||
});
|
||||
expect(res.status).toBe(502);
|
||||
});
|
||||
|
||||
it("opens a tunnel first, then the window at the resolved port", async () => {
|
||||
await start();
|
||||
const res = await fetch(`${baseUrl}/open-window`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ hostId: 7, endpointId: "e1" }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(forward).toHaveBeenCalledOnce();
|
||||
expect(mock.desktopWindows).toEqual([
|
||||
{ url: "http://127.0.0.1:41234/", title: "Proxmox", ignoreCert: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("opens a direct endpoint at the host's own address without a tunnel", async () => {
|
||||
await start({
|
||||
endpoints: [endpoint({ access: "direct", scheme: "https", port: 443 })],
|
||||
});
|
||||
const res = await fetch(`${baseUrl}/open-window`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ hostId: 7, endpointId: "e1", ignoreCert: true }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(forward).not.toHaveBeenCalled();
|
||||
expect(mock.desktopWindows).toEqual([
|
||||
{ url: "https://10.0.0.5:443/", title: "Proxmox", ignoreCert: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("ignores ignoreCert for a tunnel endpoint, whose host component is already loopback", async () => {
|
||||
await start();
|
||||
await fetch(`${baseUrl}/open-window`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ hostId: 7, endpointId: "e1", ignoreCert: true }),
|
||||
});
|
||||
expect(mock.desktopWindows[0].ignoreCert).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("route registration", () => {
|
||||
it("registers /open and /open-window", async () => {
|
||||
await start();
|
||||
const routes = createWebEndpointRoutes(mock.ctx);
|
||||
const paths = (
|
||||
routes.router as unknown as {
|
||||
routes as unknown as {
|
||||
stack: Array<{ route?: { path: string; methods: { post?: boolean } } }>;
|
||||
}
|
||||
).stack
|
||||
.filter((layer) => layer.route?.methods.post)
|
||||
.map((layer) => layer.route?.path);
|
||||
|
||||
expect(paths).toContain("/open");
|
||||
});
|
||||
|
||||
it("is reached through the plugin mount rather than a port of its own", async () => {
|
||||
const source = await readFile(
|
||||
new URL("../../src/backend/index.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
expect(source).toContain("ctx.http.router()");
|
||||
expect(paths).toEqual(expect.arrayContaining(["/open", "/open-window"]));
|
||||
});
|
||||
});
|
||||
+3
-5
@@ -4,7 +4,7 @@ import {
|
||||
normalizeWebEndpoints,
|
||||
parseWebUiConfig,
|
||||
serializeWebUiConfig,
|
||||
} from "../../../../src/backend/database/routes/host-web-endpoints.js";
|
||||
} from "../../src/shared/web-endpoint-config.js";
|
||||
|
||||
function valid(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
@@ -249,8 +249,7 @@ describe("parseWebUiConfig", () => {
|
||||
});
|
||||
|
||||
it("returns empty endpoints for malformed JSON instead of throwing", () => {
|
||||
// A half-written config must not take out the whole host listing, which
|
||||
// is what dockerConfig's bare JSON.parse does today.
|
||||
// A half-written config must not take out the whole host listing.
|
||||
expect(parseWebUiConfig("{ not json")).toEqual({ endpoints: [] });
|
||||
});
|
||||
|
||||
@@ -263,8 +262,7 @@ describe("parseWebUiConfig", () => {
|
||||
describe("serializeWebUiConfig", () => {
|
||||
it("round-trips through normalization", () => {
|
||||
expect(
|
||||
parseWebUiConfig(serializeWebUiConfig({ endpoints: [valid()] }))
|
||||
.endpoints[0].id,
|
||||
serializeWebUiConfig({ endpoints: [valid()] })?.endpoints[0].id,
|
||||
).toBe("e1");
|
||||
});
|
||||
|
||||
@@ -1,165 +0,0 @@
|
||||
import { createRequire } from "node:module";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
const { createWebEndpointWindows } = createRequire(import.meta.url)(
|
||||
"../../../../electron/web-endpoint-window.cjs",
|
||||
);
|
||||
const created: FakeWindow[] = [];
|
||||
class FakeWindow extends EventEmitter {
|
||||
options: Record<string, unknown>;
|
||||
destroyed = false;
|
||||
webContents = Object.assign(new EventEmitter(), {
|
||||
mainFrame: {},
|
||||
setWindowOpenHandler: vi.fn(),
|
||||
});
|
||||
setMenu = vi.fn();
|
||||
loadURL = vi.fn().mockResolvedValue(undefined);
|
||||
constructor(options: Record<string, unknown>) {
|
||||
super();
|
||||
this.options = options;
|
||||
created.push(this);
|
||||
}
|
||||
isDestroyed() {
|
||||
return this.destroyed;
|
||||
}
|
||||
destroy() {
|
||||
this.destroyed = true;
|
||||
this.emit("closed");
|
||||
}
|
||||
}
|
||||
let main: FakeWindow;
|
||||
type TestSession = EventEmitter & {
|
||||
setPermissionRequestHandler: ReturnType<typeof vi.fn>;
|
||||
setPermissionCheckHandler: ReturnType<typeof vi.fn>;
|
||||
webRequest: { onBeforeRequest: ReturnType<typeof vi.fn> };
|
||||
clearStorageData: ReturnType<typeof vi.fn>;
|
||||
clearCache: ReturnType<typeof vi.fn>;
|
||||
closeAllConnections: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
let sessions: TestSession[];
|
||||
let fromPartition: ReturnType<typeof vi.fn>;
|
||||
let manager: ReturnType<typeof createWebEndpointWindows>;
|
||||
function event() {
|
||||
return { sender: main.webContents, senderFrame: main.webContents.mainFrame };
|
||||
}
|
||||
beforeEach(() => {
|
||||
created.length = 0;
|
||||
sessions = [];
|
||||
main = new FakeWindow({});
|
||||
created.length = 0;
|
||||
fromPartition = vi.fn(() => {
|
||||
const session = Object.assign(new EventEmitter(), {
|
||||
setPermissionRequestHandler: vi.fn(),
|
||||
setPermissionCheckHandler: vi.fn(),
|
||||
webRequest: { onBeforeRequest: vi.fn() },
|
||||
clearStorageData: vi.fn().mockResolvedValue(undefined),
|
||||
clearCache: vi.fn().mockResolvedValue(undefined),
|
||||
closeAllConnections: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
sessions.push(session);
|
||||
return session;
|
||||
});
|
||||
manager = createWebEndpointWindows({
|
||||
BrowserWindow: FakeWindow,
|
||||
session: { fromPartition },
|
||||
getMainWindow: () => main,
|
||||
});
|
||||
});
|
||||
describe("isolated endpoint windows", () => {
|
||||
it("creates unique non-persistent sessions without preload or Node privileges", async () => {
|
||||
await manager.open(event(), { url: "https://service.test" });
|
||||
await manager.open(event(), { url: "https://service.test" });
|
||||
expect(fromPartition.mock.calls[0][0]).not.toBe(
|
||||
fromPartition.mock.calls[1][0],
|
||||
);
|
||||
expect(fromPartition.mock.calls[0][0]).not.toMatch(/^persist:/);
|
||||
expect(created[0].options.webPreferences).toEqual({
|
||||
session: sessions[0],
|
||||
sandbox: true,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
webviewTag: false,
|
||||
webSecurity: true,
|
||||
});
|
||||
});
|
||||
it("rejects subframes, other windows and non-web destinations before creating a session", async () => {
|
||||
await expect(
|
||||
manager.open(
|
||||
{ ...event(), senderFrame: {} },
|
||||
{ url: "https://service.test" },
|
||||
),
|
||||
).rejects.toThrow(/main Termix/);
|
||||
await expect(
|
||||
manager.open({ ...event(), sender: {} }, { url: "https://service.test" }),
|
||||
).rejects.toThrow(/main Termix/);
|
||||
for (const url of [
|
||||
"file:///etc/passwd",
|
||||
"javascript:alert(1)",
|
||||
"https://user:secret@service.test",
|
||||
]) {
|
||||
await expect(manager.open(event(), { url })).rejects.toThrow();
|
||||
}
|
||||
expect(fromPartition).not.toHaveBeenCalled();
|
||||
});
|
||||
it("keeps login popups in the same isolated session and blocks native-protocol navigation", async () => {
|
||||
await manager.open(event(), { url: "https://service.test" });
|
||||
const win = created[0],
|
||||
open = win.webContents.setWindowOpenHandler.mock.calls[0][0];
|
||||
expect(
|
||||
open({ url: "https://login.test" }).overrideBrowserWindowOptions
|
||||
.webPreferences.session,
|
||||
).toBe(sessions[0]);
|
||||
expect(open({ url: "file:///tmp/secret" }).action).toBe("deny");
|
||||
const navigation = { preventDefault: vi.fn() };
|
||||
win.webContents.emit("will-redirect", navigation, "file:///tmp/secret");
|
||||
expect(navigation.preventDefault).toHaveBeenCalled();
|
||||
const callback = vi.fn();
|
||||
sessions[0].webRequest.onBeforeRequest.mock.calls[0][0](
|
||||
{ url: "file:///tmp/secret" },
|
||||
callback,
|
||||
);
|
||||
expect(callback).toHaveBeenCalledWith({ cancel: true });
|
||||
});
|
||||
it("limits invalid certificates to the opted-in origin, including its port", async () => {
|
||||
await manager.open(event(), {
|
||||
url: "https://service.test:8443",
|
||||
ignoreCert: true,
|
||||
});
|
||||
const e = { preventDefault: vi.fn() },
|
||||
callback = vi.fn();
|
||||
expect(
|
||||
manager.handleCertificateError(
|
||||
e,
|
||||
created[0].webContents,
|
||||
"https://service.test:8443/path",
|
||||
callback,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(callback).toHaveBeenLastCalledWith(true);
|
||||
manager.handleCertificateError(
|
||||
e,
|
||||
created[0].webContents,
|
||||
"https://service.test:9443",
|
||||
callback,
|
||||
);
|
||||
expect(callback).toHaveBeenLastCalledWith(false);
|
||||
expect(
|
||||
manager.handleCertificateError(
|
||||
e,
|
||||
main.webContents,
|
||||
"https://service.test:8443",
|
||||
callback,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
it("closes login popups and clears session data when the endpoint closes", async () => {
|
||||
await manager.open(event(), { url: "https://service.test" });
|
||||
const root = created[0],
|
||||
popup = new FakeWindow({});
|
||||
root.webContents.emit("did-create-window", popup);
|
||||
root.destroy();
|
||||
expect(popup.isDestroyed()).toBe(true);
|
||||
expect(sessions[0].clearStorageData).toHaveBeenCalledOnce();
|
||||
expect(sessions[0].closeAllConnections).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -1,186 +0,0 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import type { WebEndpoint, WebUiConfig } from "@/types/index";
|
||||
|
||||
const isElectron = vi.hoisted(() => vi.fn(() => true));
|
||||
vi.mock("@/lib/electron", () => ({ isElectron }));
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, vars?: Record<string, unknown>) =>
|
||||
vars ? `${key}:${JSON.stringify(vars)}` : key,
|
||||
}),
|
||||
}));
|
||||
|
||||
import { HostEditorWebUiSection } from "../../src/frontend/HostEditorWebUiSection";
|
||||
|
||||
function endpoint(overrides: Partial<WebEndpoint> = {}): WebEndpoint {
|
||||
return {
|
||||
id: "e1",
|
||||
label: "Proxmox",
|
||||
scheme: "https",
|
||||
port: 8006,
|
||||
path: "/",
|
||||
access: "direct",
|
||||
render: "external",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function setup(
|
||||
config: WebUiConfig = { endpoints: [] },
|
||||
{
|
||||
enableWebUi = true,
|
||||
tunnelAvailable = true,
|
||||
}: { enableWebUi?: boolean; tunnelAvailable?: boolean } = {},
|
||||
) {
|
||||
const setField = vi.fn();
|
||||
render(
|
||||
<HostEditorWebUiSection
|
||||
enableWebUi={enableWebUi}
|
||||
webUiConfig={config}
|
||||
tunnelAvailable={tunnelAvailable}
|
||||
setField={setField}
|
||||
/>,
|
||||
);
|
||||
return setField;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
isElectron.mockReturnValue(true);
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("HostEditorWebUiSection", () => {
|
||||
it("hides the endpoint list until the feature is enabled", () => {
|
||||
setup({ endpoints: [endpoint()] }, { enableWebUi: false });
|
||||
expect(screen.queryByLabelText("hosts.webUiLabel")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("hosts.webUiAddEndpoint"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("adds an endpoint with a non-colliding label", () => {
|
||||
const setField = setup({
|
||||
endpoints: [endpoint({ label: "hosts.webUiNewEndpointLabel" })],
|
||||
});
|
||||
fireEvent.click(screen.getByText("hosts.webUiAddEndpoint"));
|
||||
|
||||
const [, value] = setField.mock.calls[0];
|
||||
const added = (value as WebUiConfig).endpoints[1];
|
||||
// Labels identify an endpoint in the sidebar picker, so a fresh row must
|
||||
// not silently duplicate one already there.
|
||||
expect(added.label).toBe("hosts.webUiNewEndpointLabel 2");
|
||||
expect(added.id).toBeTruthy();
|
||||
});
|
||||
|
||||
it("stops adding at the cap", () => {
|
||||
const many = Array.from({ length: 16 }, (_, i) =>
|
||||
endpoint({ id: `e${i}`, label: `E${i}` }),
|
||||
);
|
||||
setup({ endpoints: many });
|
||||
expect(screen.getByText("hosts.webUiAddEndpoint")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("refuses to commit a port the normalizer would drop", () => {
|
||||
// Number("") is 0, which the normalizer rejects -- committing it would
|
||||
// silently discard the endpoint on save with no error shown.
|
||||
const setField = setup({ endpoints: [endpoint()] });
|
||||
fireEvent.change(screen.getByLabelText("hosts.webUiPort"), {
|
||||
target: { value: "" },
|
||||
});
|
||||
expect(setField).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.change(screen.getByLabelText("hosts.webUiPort"), {
|
||||
target: { value: "9000" },
|
||||
});
|
||||
expect((setField.mock.calls[0][1] as WebUiConfig).endpoints[0].port).toBe(
|
||||
9000,
|
||||
);
|
||||
});
|
||||
|
||||
it("shows the bind-host fields only for a tunnel endpoint", () => {
|
||||
setup({ endpoints: [endpoint({ access: "direct" })] });
|
||||
expect(screen.queryByLabelText("hosts.bindHost")).not.toBeInTheDocument();
|
||||
|
||||
cleanup();
|
||||
setup({ endpoints: [endpoint({ access: "tunnel" })] });
|
||||
expect(screen.getByLabelText("hosts.bindHost")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("hosts.webUiLocalPort")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("warns that a non-loopback bind is unauthenticated exposure", () => {
|
||||
setup({ endpoints: [endpoint({ access: "tunnel", bindHost: "0.0.0.0" })] });
|
||||
expect(screen.getByText("hosts.webUiBindHostExposed")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not warn about exposure for a loopback bind", () => {
|
||||
setup({
|
||||
endpoints: [endpoint({ access: "tunnel", bindHost: "127.0.0.1" })],
|
||||
});
|
||||
expect(
|
||||
screen.queryByText("hosts.webUiBindHostExposed"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("warns at config time that a loopback bind is unreachable from a browser", () => {
|
||||
isElectron.mockReturnValue(false);
|
||||
setup({ endpoints: [endpoint({ access: "tunnel" })] });
|
||||
// Said while configuring, not only when the tab fails to load.
|
||||
expect(
|
||||
screen.getByText("hosts.webUiBindHostUnreachable"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("warns at config time about the session-cookie collision", () => {
|
||||
// The page host in jsdom is "localhost", which HAS a loopback alias, so
|
||||
// force a host that does not to reach the second refusal.
|
||||
isElectron.mockReturnValue(false);
|
||||
const realLocation = window.location;
|
||||
Object.defineProperty(window, "location", {
|
||||
configurable: true,
|
||||
get: () => ({ ...realLocation, hostname: "termix.example.com" }),
|
||||
});
|
||||
|
||||
setup({ endpoints: [endpoint({ access: "tunnel", bindHost: "0.0.0.0" })] });
|
||||
expect(
|
||||
screen.getByText("hosts.webUiBindHostSharesSessionCookie"),
|
||||
).toBeInTheDocument();
|
||||
|
||||
Object.defineProperty(window, "location", {
|
||||
configurable: true,
|
||||
value: realLocation,
|
||||
});
|
||||
});
|
||||
|
||||
it("explains why tunnelling is unavailable rather than leaving a dead option", () => {
|
||||
// A disabled control with no stated reason reads as the dropdown being
|
||||
// broken.
|
||||
setup({ endpoints: [endpoint()] }, { tunnelAvailable: false });
|
||||
expect(
|
||||
screen.getByText("hosts.webUiAccessTunnelUnavailable"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("reports a row the normalizer would drop", () => {
|
||||
setup({ endpoints: [endpoint({ label: " " })] });
|
||||
expect(
|
||||
screen.getByText(/hosts.webUiErrorLabelRequired/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("removes an endpoint", () => {
|
||||
const setField = setup({
|
||||
endpoints: [endpoint(), endpoint({ id: "e2", label: "NAS" })],
|
||||
});
|
||||
fireEvent.click(screen.getAllByLabelText("hosts.webUiRemoveEndpoint")[0]);
|
||||
expect((setField.mock.calls[0][1] as WebUiConfig).endpoints).toHaveLength(
|
||||
1,
|
||||
);
|
||||
expect((setField.mock.calls[0][1] as WebUiConfig).endpoints[0].id).toBe(
|
||||
"e2",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
import type { WebEndpoint } from "@/types/index";
|
||||
import type { WebEndpoint } from "../../src/shared/web-endpoint-config";
|
||||
import type { Host } from "@/types/ui-types";
|
||||
|
||||
const openWebEndpointTunnel = vi.hoisted(() => vi.fn());
|
||||
@@ -59,7 +59,7 @@ function host(
|
||||
return {
|
||||
id: "7",
|
||||
ip: "192.168.1.10",
|
||||
webUiConfig: { endpoints },
|
||||
pluginSettings: { "web-endpoint": { webUiConfig: { endpoints } } },
|
||||
...overrides,
|
||||
} as unknown as Host;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent } from "@testing-library/react";
|
||||
import {
|
||||
renderWithApp,
|
||||
type RenderedPluginApp,
|
||||
} from "@termix/plugin-sdk/testing";
|
||||
import { hostActionsFor, listHostActions } from "@/sidebar/host-contributions";
|
||||
import type { Host } from "@/types/ui-types";
|
||||
import type { WebEndpoint } from "@/types";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import type { WebEndpoint } from "../../src/shared/web-endpoint-config";
|
||||
import * as plugin from "../../src/frontend/index";
|
||||
import manifestJson from "../../manifest.json";
|
||||
import locales from "../../locales/en.json";
|
||||
|
||||
const manifest = manifestJson as unknown as PluginManifest;
|
||||
|
||||
@@ -25,13 +27,19 @@ function endpoint(overrides: Partial<WebEndpoint> = {}): WebEndpoint {
|
||||
};
|
||||
}
|
||||
|
||||
function host(overrides: Partial<Host> = {}): Host {
|
||||
function host(
|
||||
endpoints: WebEndpoint[] | undefined,
|
||||
overrides: Partial<Host> = {},
|
||||
): Host {
|
||||
return {
|
||||
id: "7",
|
||||
ip: "10.0.0.5",
|
||||
name: "nas",
|
||||
enableSsh: false,
|
||||
enableWebUi: false,
|
||||
pluginSettings:
|
||||
endpoints === undefined
|
||||
? {}
|
||||
: { "web-endpoint": { enableWebUi: true, webUiConfig: { endpoints } } },
|
||||
...overrides,
|
||||
} as unknown as Host;
|
||||
}
|
||||
@@ -58,37 +66,32 @@ describe("web-endpoint activate", () => {
|
||||
});
|
||||
|
||||
it("offers no Web UI entry when the feature is off, even with endpoints", async () => {
|
||||
expect(
|
||||
await webAction(
|
||||
host({ enableWebUi: false, webUiConfig: { endpoints: [endpoint()] } }),
|
||||
),
|
||||
).toBeUndefined();
|
||||
const target = host([endpoint()], {
|
||||
pluginSettings: {
|
||||
"web-endpoint": {
|
||||
enableWebUi: false,
|
||||
webUiConfig: { endpoints: [endpoint()] },
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(await webAction(target)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("offers no entry when enabled but no endpoints exist", async () => {
|
||||
expect(
|
||||
await webAction(
|
||||
host({ enableWebUi: true, webUiConfig: { endpoints: [] } }),
|
||||
),
|
||||
).toBeUndefined();
|
||||
expect(await webAction(host([]))).toBeUndefined();
|
||||
});
|
||||
|
||||
it("offers no entry when the host has no web-endpoint settings at all", async () => {
|
||||
expect(await webAction(host(undefined))).toBeUndefined();
|
||||
});
|
||||
|
||||
it("appears without SSH", async () => {
|
||||
const action = await webAction(
|
||||
host({
|
||||
enableSsh: false,
|
||||
enableWebUi: true,
|
||||
webUiConfig: { endpoints: [endpoint()] },
|
||||
}),
|
||||
);
|
||||
const action = await webAction(host([endpoint()], { enableSsh: false }));
|
||||
expect(action).toBeDefined();
|
||||
});
|
||||
|
||||
it("labels the entry with the endpoint when there is only one", async () => {
|
||||
const target = host({
|
||||
enableWebUi: true,
|
||||
webUiConfig: { endpoints: [endpoint({ label: "Proxmox" })] },
|
||||
});
|
||||
const target = host([endpoint({ label: "Proxmox" })]);
|
||||
const action = await webAction(target);
|
||||
expect(action?.label?.(target)).toBe("Proxmox");
|
||||
expect(action?.items?.(target).map((item) => item.id)).toEqual(["e1"]);
|
||||
@@ -98,17 +101,14 @@ describe("web-endpoint activate", () => {
|
||||
const endpoints = Array.from({ length: 16 }, (_, i) =>
|
||||
endpoint({ id: `e${i}`, label: `Endpoint ${i}` }),
|
||||
);
|
||||
const target = host({ enableWebUi: true, webUiConfig: { endpoints } });
|
||||
const target = host(endpoints);
|
||||
const action = await webAction(target);
|
||||
expect(action?.label?.(target)).toBeUndefined();
|
||||
expect(action?.items?.(target)).toHaveLength(16);
|
||||
});
|
||||
|
||||
it("opens an embedded endpoint as a tab carrying its id", async () => {
|
||||
const target = host({
|
||||
enableWebUi: true,
|
||||
webUiConfig: { endpoints: [endpoint()] },
|
||||
});
|
||||
const target = host([endpoint()]);
|
||||
const action = await webAction(target);
|
||||
const calls: unknown[][] = [];
|
||||
const shell = {
|
||||
@@ -124,3 +124,36 @@ describe("web-endpoint activate", () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("host editor section", () => {
|
||||
it("writes into the form's web-endpoint plugin settings", async () => {
|
||||
rendered = await renderWithApp(plugin, { manifest, locales });
|
||||
let form: Record<string, unknown> = {
|
||||
name: "nas",
|
||||
pluginSettings: { "web-endpoint": { enableWebUi: false } },
|
||||
};
|
||||
const updateForm = vi.fn(
|
||||
(
|
||||
patch: (current: Record<string, unknown>) => Record<string, unknown>,
|
||||
) => {
|
||||
form = patch(form);
|
||||
},
|
||||
);
|
||||
|
||||
const section = rendered.renderHostEditorSection("web-ui", {
|
||||
form,
|
||||
setField: vi.fn(),
|
||||
updateForm,
|
||||
protocols: { enableSsh: true },
|
||||
});
|
||||
|
||||
// The enable switch is the only button on the section before any
|
||||
// endpoint rows exist (which only render once enabled).
|
||||
fireEvent.click(section.querySelector("button") as HTMLButtonElement);
|
||||
|
||||
const settings = (form.pluginSettings as Record<string, unknown>)[
|
||||
"web-endpoint"
|
||||
] as { enableWebUi: boolean };
|
||||
expect(settings.enableWebUi).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,10 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { WebEndpoint } from "@/types/index";
|
||||
import type { WebEndpoint } from "../../src/shared/web-endpoint-config";
|
||||
|
||||
const isElectron = vi.hoisted(() => vi.fn(() => false));
|
||||
vi.mock("@/lib/electron", () => ({ isElectron }));
|
||||
|
||||
const tunnelPost = vi.hoisted(() => vi.fn());
|
||||
const pluginPost = vi.hoisted(() => vi.fn());
|
||||
vi.mock("@/main-axios", () => ({
|
||||
handleApiError: (error: unknown) => {
|
||||
throw new Error(`generic: ${String(error)}`);
|
||||
@@ -27,32 +27,17 @@ function endpoint(overrides: Partial<WebEndpoint> = {}): WebEndpoint {
|
||||
|
||||
const host = { id: "7", ip: "192.168.1.10" };
|
||||
|
||||
let pageHostname = "localhost";
|
||||
const realLocation = window.location;
|
||||
const windowOpen = vi.fn();
|
||||
|
||||
beforeEach(async () => {
|
||||
const { setWebEndpointApi } =
|
||||
await import("../../src/frontend/web-endpoint-api");
|
||||
setWebEndpointApi({ post: tunnelPost } as never);
|
||||
pageHostname = "localhost";
|
||||
setWebEndpointApi({ post: pluginPost } as never);
|
||||
isElectron.mockReturnValue(false);
|
||||
tunnelPost.mockReset();
|
||||
tunnelPost.mockResolvedValue({ data: { port: 41234 } });
|
||||
Object.defineProperty(window, "location", {
|
||||
configurable: true,
|
||||
get: () => ({ ...realLocation, hostname: pageHostname }),
|
||||
});
|
||||
vi.stubGlobal("open", windowOpen);
|
||||
pluginPost.mockReset();
|
||||
pluginPost.mockResolvedValue({ data: { port: 41234 } });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(window, "location", {
|
||||
configurable: true,
|
||||
value: realLocation,
|
||||
});
|
||||
vi.unstubAllGlobals();
|
||||
windowOpen.mockReset();
|
||||
});
|
||||
|
||||
describe("openWebEndpointTunnel", () => {
|
||||
@@ -63,7 +48,7 @@ describe("openWebEndpointTunnel", () => {
|
||||
await import("../../src/frontend/web-endpoint-api");
|
||||
await openWebEndpointTunnel(7, "e1");
|
||||
|
||||
expect(tunnelPost).toHaveBeenCalledWith("/open", {
|
||||
expect(pluginPost).toHaveBeenCalledWith("/open", {
|
||||
hostId: 7,
|
||||
endpointId: "e1",
|
||||
});
|
||||
@@ -86,7 +71,7 @@ describe("openWebEndpointTunnel", () => {
|
||||
data: { error: "Timed out reaching the endpoint port" },
|
||||
},
|
||||
});
|
||||
tunnelPost.mockRejectedValue(axiosError);
|
||||
pluginPost.mockRejectedValue(axiosError);
|
||||
const { openWebEndpointTunnel, WebEndpointTunnelError } =
|
||||
await import("../../src/frontend/web-endpoint-api");
|
||||
|
||||
@@ -101,7 +86,7 @@ describe("openWebEndpointTunnel", () => {
|
||||
it("falls back to the shared handler when the body carries no string reason", async () => {
|
||||
// A body shaped { error: <object> } would otherwise render as
|
||||
// "[object Object]" to the user.
|
||||
tunnelPost.mockRejectedValue(
|
||||
pluginPost.mockRejectedValue(
|
||||
Object.assign(new Error("boom"), {
|
||||
isAxiosError: true,
|
||||
response: { status: 500, data: { error: { nested: true } } },
|
||||
@@ -113,7 +98,7 @@ describe("openWebEndpointTunnel", () => {
|
||||
});
|
||||
|
||||
it("treats a missing port as a failure rather than returning undefined", async () => {
|
||||
tunnelPost.mockResolvedValue({ data: {} });
|
||||
pluginPost.mockResolvedValue({ data: {} });
|
||||
const { openWebEndpointTunnel } =
|
||||
await import("../../src/frontend/web-endpoint-api");
|
||||
await expect(openWebEndpointTunnel(7, "e1")).rejects.toThrow(/no port/);
|
||||
@@ -132,35 +117,45 @@ describe("requireNumericHostId", () => {
|
||||
});
|
||||
|
||||
/**
|
||||
* "Open externally" is not the safer path. The cookie jar belongs to the
|
||||
* browser either way, so a tunnel URL on the host string serving Termix hands
|
||||
* the tunnelled service this session exactly as an embedded frame would.
|
||||
* The backend now resolves and validates the target URL itself (host address,
|
||||
* tunnel port, loopback/session-cookie checks) before ever calling
|
||||
* ctx.desktop.openIsolatedWindow, so this call is a thin ask-and-report over
|
||||
* the plugin's own /open-window route rather than a client-side URL builder.
|
||||
*/
|
||||
describe("openWebEndpointExternally", () => {
|
||||
it("refuses shared-cookie browser windows before opening a tunnel", async () => {
|
||||
it("refuses outside the desktop app before calling the backend", async () => {
|
||||
const { openWebEndpointExternally } =
|
||||
await import("../../src/frontend/web-endpoint-api");
|
||||
await expect(openWebEndpointExternally(host, endpoint())).rejects.toThrow(
|
||||
/desktop app/,
|
||||
);
|
||||
expect(windowOpen).not.toHaveBeenCalled();
|
||||
expect(tunnelPost).not.toHaveBeenCalled();
|
||||
expect(pluginPost).not.toHaveBeenCalled();
|
||||
});
|
||||
it("opens desktop endpoints through the isolated-window bridge", async () => {
|
||||
|
||||
it("posts hostId, endpointId and ignoreCert to /open-window", async () => {
|
||||
isElectron.mockReturnValue(true);
|
||||
const invoke = vi.fn().mockResolvedValue({ success: true });
|
||||
Object.defineProperty(window, "electronAPI", {
|
||||
configurable: true,
|
||||
value: { invoke },
|
||||
});
|
||||
pluginPost.mockResolvedValue({ data: { success: true } });
|
||||
const { openWebEndpointExternally } =
|
||||
await import("../../src/frontend/web-endpoint-api");
|
||||
await openWebEndpointExternally(host, endpoint({ ignoreCert: true }));
|
||||
expect(invoke).toHaveBeenCalledWith("open-isolated-web-endpoint", {
|
||||
url: "https://127.0.0.1:41234/",
|
||||
expect(pluginPost).toHaveBeenCalledWith("/open-window", {
|
||||
hostId: 7,
|
||||
endpointId: "e1",
|
||||
ignoreCert: true,
|
||||
});
|
||||
expect(windowOpen).not.toHaveBeenCalled();
|
||||
delete (window as unknown as { electronAPI?: unknown }).electronAPI;
|
||||
});
|
||||
|
||||
it("preserves the backend's reason instead of the generic message", async () => {
|
||||
isElectron.mockReturnValue(true);
|
||||
const axiosError = Object.assign(new Error("Request failed"), {
|
||||
isAxiosError: true,
|
||||
response: { status: 502, data: { error: "No window to attach to" } },
|
||||
});
|
||||
pluginPost.mockRejectedValue(axiosError);
|
||||
const { openWebEndpointExternally, WebEndpointTunnelError } =
|
||||
await import("../../src/frontend/web-endpoint-api");
|
||||
await expect(
|
||||
openWebEndpointExternally(host, endpoint()),
|
||||
).rejects.toBeInstanceOf(WebEndpointTunnelError);
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
sharesCookieSiteWithPage,
|
||||
webEndpointRefusalReason,
|
||||
} from "../../src/frontend/web-endpoint-url";
|
||||
import type { WebEndpoint } from "@/types/index";
|
||||
import type { WebEndpoint } from "../../src/shared/web-endpoint-config";
|
||||
|
||||
function endpoint(overrides: Partial<WebEndpoint> = {}): WebEndpoint {
|
||||
return {
|
||||
|
||||
@@ -6,8 +6,8 @@ import {
|
||||
webEndpointErrorKey,
|
||||
webEndpointRowError,
|
||||
} from "../../src/frontend/web-endpoint-validation";
|
||||
import { normalizeWebEndpoints } from "../../../../src/backend/database/routes/host-web-endpoints.js";
|
||||
import type { WebEndpoint } from "@/types/index";
|
||||
import { normalizeWebEndpoints } from "../../src/shared/web-endpoint-config";
|
||||
import type { WebEndpoint } from "../../src/shared/web-endpoint-config";
|
||||
|
||||
function endpoint(overrides: Partial<WebEndpoint> = {}): WebEndpoint {
|
||||
return {
|
||||
|
||||
Reference in new issue
Block a user