feat: finish the web-endpoint plugin, add ctx.desktop (B8)

This commit is contained in:
LukeGus committed 2026-09-23 18:36:41 -05:00
1 parent 1682861853
commit 535126fd1b
50 files changed
+2018 -898

No files matched your search

@@ -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,
};
}
+19 -6
View File
@@ -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() {}
+174 -70
View File
@@ -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,
);
+25 -17
View File
@@ -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 };
}