feat: make each plugin an npm workspace built by a termix-plugin CLI

This commit is contained in:
LukeGus committed 2026-09-22 15:09:50 -05:00
1 parent 4758016f41
commit 5796186a1c
448 files changed
+3087 -2003

No files matched your search

+11
View File
@@ -0,0 +1,11 @@
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { startWebEndpointService, stopWebEndpointService } from "./routes.js";
export async function activate(ctx: PluginContext) {
startWebEndpointService();
ctx.log.info("Web Endpoint router registered at /ssh/tunnel/web-endpoint");
}
export async function deactivate() {
stopWebEndpointService();
}
+323
View File
@@ -0,0 +1,323 @@
import express from "express";
import { Client } from "ssh2";
import { AuthManager } from "../../../../src/backend/utils/auth-manager.js";
import { tunnelLogger } from "../../../../src/backend/utils/logger.js";
import { parseWebUiConfig } from "../../../../src/backend/database/routes/host-web-endpoints.js";
import {
activeTunnelRuntimes,
cleanupTunnelResources,
connectSSHTunnel,
connectionStatus,
tunnelConnecting,
} from "../../../../src/backend/hosts/tunnel/manager.js";
import { forwardOut } from "../../../../src/backend/hosts/tunnel/ssh-primitives.js";
import { buildWebEndpointTunnelName } from "../../../../src/backend/hosts/tunnel/utils.js";
import {
registerWebEndpointRouter,
unregisterWebEndpointRouter,
} from "../../../../src/backend/hosts/tunnel/web-endpoint-dispatch.js";
import type { TunnelConfig, WebEndpoint } from "../../../../src/types/index.js";
/** Matches the spec's ten minutes. */
const WEB_ENDPOINT_IDLE_TIMEOUT_MS = 10 * 60 * 1000;
/**
* Long enough for a slow SSH handshake, short enough that a wedged connection
* does not hold the HTTP request open indefinitely. One second past the
* manager's own connection timeout so the manager reports first.
*/
const TUNNEL_READY_TIMEOUT_MS = 61_000;
const TUNNEL_READY_POLL_MS = 100;
const TARGET_PROBE_TIMEOUT_MS = 10_000;
/**
* What the live forward was built for. Compared on reuse so an endpoint edited
* under a live tunnel is reopened rather than silently forwarding to the old
* target.
*
* JSON rather than a joined string: no separator can be smuggled in by a field
* value, and the result stays readable in a log. The SSH identity is included
* because a host repointed at a different server must also invalidate the
* forward, not just a changed endpoint port.
*/
function targetFingerprint(
endpoint: Pick<
WebEndpoint,
"scheme" | "port" | "access" | "bindHost" | "localPort"
>,
host: { ip?: string; port?: number; username?: string },
): string {
return JSON.stringify({
scheme: endpoint.scheme,
port: endpoint.port,
access: endpoint.access,
bindHost: endpoint.bindHost ?? null,
localPort: endpoint.localPort ?? null,
ip: host.ip ?? null,
sshPort: host.port ?? null,
username: host.username ?? null,
});
}
const openedTargets = new Map<string, string>();
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* connectSSHTunnel(config) returns Promise<void> and NEVER REJECTS: every
* failure path inside it reports over broadcastTunnelStatus and returns
* normally, and its own promise resolves right after conn.connect(), long
* before the SSH "ready" event that would populate activeTunnelRuntimes.
*
* So "awaited" is not "connected". This is what actually waits.
*
* Polling the maps the manager already exports, rather than adding a
* promise-returning variant to manager.ts: every existing caller of
* connectSSHTunnel is fire-and-forget by design (the connect route responds
* "Connection request received" immediately and reports asynchronously over
* the status broadcast), so changing that contract to serve one new caller is
* a far larger blast radius than polling here.
*/
async function waitForTunnelSettled(tunnelName: string): Promise<void> {
const deadline = Date.now() + TUNNEL_READY_TIMEOUT_MS;
while (Date.now() < deadline) {
if (activeTunnelRuntimes.has(tunnelName)) return;
const status = connectionStatus.get(tunnelName);
if (!tunnelConnecting.has(tunnelName) && status && !status.connected) {
throw new Error(status.reason || "The endpoint tunnel failed to connect");
}
await sleep(TUNNEL_READY_POLL_MS);
}
throw new Error("Timed out establishing the endpoint tunnel");
}
/**
* Opens one channel to the target and closes it immediately.
*
* Without this the route returns 200 the moment tcpServer.listen succeeds, and
* forwardOut is only attempted per inbound socket -- where a failure is
* swallowed by the socket's own error handling. The user then gets a blank
* frame and no message for the single likeliest error: nothing is actually
* listening on that port. Costs one round trip per open.
*/
async function probeEndpointTarget(
sourceClient: Client,
targetPort: number,
tunnelName: string,
): Promise<void> {
const channel = await Promise.race([
forwardOut(sourceClient, "127.0.0.1", targetPort, tunnelName),
new Promise<never>((_resolve, reject) =>
setTimeout(
() => reject(new Error("Timed out reaching the endpoint port")),
TARGET_PROBE_TIMEOUT_MS,
),
),
]);
try {
channel.end();
} catch {
// Already gone; the probe succeeded either way.
}
}
export async function handleWebEndpointOpen(
req: express.Request,
res: express.Response,
): Promise<express.Response | void> {
const userId = (req as { userId?: string }).userId;
if (!userId) {
return res.status(401).json({ error: "Authentication required" });
}
const { hostId, endpointId } = req.body ?? {};
if (
!Number.isInteger(hostId) ||
hostId < 1 ||
typeof endpointId !== "string"
) {
return res.status(400).json({ error: "Invalid web endpoint request" });
}
// Deliberately NOT gated to the desktop. The forward binds wherever this
// backend runs, exactly as the server tunnels feature does, and the
// endpoint's own bindHost decides whether that is reachable from the
// browser -- loopback for the desktop, an address the server answers on for
// a web deployment.
const { resolveHostById } =
await import("../../../../src/backend/hosts/host-resolver.js");
const host = await resolveHostById(hostId, userId);
if (!host) {
return res.status(403).json({ error: "Host not found or access denied" });
}
// All three write paths null the config on disable, so a configured-but-
// disabled host is not normally reachable -- except a bulk update that sends
// only { webUiConfig } while the stored enable_web_ui is still 0. The UI
// reads as off everywhere in that state, so the endpoint merely being
// present in the config is not on its own a licence to open a tunnel.
if (!host.enableWebUi) {
return res
.status(400)
.json({ error: "Web endpoints are not enabled for this host" });
}
// Re-normalized rather than trusted: the stored value predates any later
// tightening of the rules, and this is the value a forward is built from.
const endpoint = parseWebUiConfig(host.webUiConfig).endpoints.find(
(candidate) => candidate.id === endpointId,
);
if (!endpoint) {
return res.status(400).json({ error: "Web endpoint not found" });
}
if (endpoint.access !== "tunnel") {
return res
.status(400)
.json({ error: "This endpoint does not use a tunnel" });
}
const tunnelName = buildWebEndpointTunnelName(hostId, endpointId);
const fingerprint = targetFingerprint(endpoint, host);
const existing = activeTunnelRuntimes.get(tunnelName);
if (existing) {
const recorded = openedTargets.get(tunnelName);
// The runtime is the source of truth and openedTargets is derived from it,
// so an ABSENT fingerprint means reuse, not staleness. A reserved-prefixed
// runtime can only have been created by this route.
if (recorded === undefined || recorded === fingerprint) {
openedTargets.set(tunnelName, fingerprint);
return res.status(200).json({ port: existing.bindPort });
}
tunnelLogger.info("Reopening web endpoint tunnel after config change", {
operation: "web_endpoint_reopen",
tunnelName,
});
// Forced, for the same reason handleDisconnect's reserved-name branch
// forces: cleanupTunnelResources no-ops while tunnelConnecting holds the
// name, and a web tunnel has no retry pass to self-heal a runtime left
// behind. An unforced call could leave a dead listener registered.
await cleanupTunnelResources(tunnelName, true);
openedTargets.delete(tunnelName);
}
const tunnelConfig: TunnelConfig = {
name: tunnelName,
scope: "s2s",
mode: "local",
tunnelType: "local",
// Loopback unless the endpoint asks otherwise. A non-loopback bind
// publishes the target's web UI to anyone who can reach the port, with no
// authentication in front of it -- the same trade the server tunnels
// feature exposes, and only ever an explicit per-endpoint choice. The
// value is validated to a bare host literal by normalizeBindHost.
bindHost: endpoint.bindHost || "127.0.0.1",
sourceHostId: host.id ?? hostId,
tunnelIndex: 0,
requestingUserId: userId,
hostName: host.name || `${host.username}@${host.ip}`,
sourceIP: host.ip,
sourceSSHPort: host.port,
sourceUsername: host.username,
sourcePassword: host.password,
sourceAuthMethod: host.authType,
sourceSSHKey: host.key,
sourceKeyPassword: host.keyPassword,
sourceKeyType: host.keyType,
sourceCredentialId: host.credentialId,
// connectSSHTunnel reads sourceUserId when building its client.
sourceUserId: host.userId,
endpointIP: host.ip,
endpointSSHPort: host.port,
endpointUsername: host.username,
// Load-bearing: connectSSHTunnel picks its strategy via
// shouldEstablishDirectTunnel -> isSingleHostTunnel, which returns true
// when endpointHost is "127.0.0.1". That is what selects
// establishDirectTunnel's local branch -- the bind-locally-and-forwardOut
// path this feature needs. Setting only targetHost would NOT select it.
endpointHost: "127.0.0.1",
endpointAuthMethod: host.authType,
endpointSSHKey: host.key,
endpointKeyPassword: host.keyPassword,
endpointKeyType: host.keyType,
endpointCredentialId: host.credentialId,
endpointUserId: host.userId,
// A fixed port when the endpoint names one, as the server tunnels
// feature's Source Port does -- a container can only publish ports it
// knows in advance. Otherwise 0 and the kernel picks; the manager reports
// back what it actually bound either way.
sourcePort: endpoint.localPort ?? 0,
endpointPort: endpoint.port,
// Documentation only: maxRetries 0 does NOT disable retries, because the
// manager reads `maxRetries || 3`. Retry is actually suppressed by the
// reserved-name early return in handleDisconnect.
maxRetries: 0,
retryInterval: 0,
autoStart: false,
isPinned: Boolean(host.pin),
idleTimeoutMs: WEB_ENDPOINT_IDLE_TIMEOUT_MS,
useSocks5: Boolean(host.useSocks5),
socks5Host: host.socks5Host,
socks5Port: host.socks5Port,
socks5Username: host.socks5Username,
socks5Password: host.socks5Password,
socks5ProxyChain: host.socks5ProxyChain,
} as TunnelConfig;
try {
await connectSSHTunnel(tunnelConfig);
await waitForTunnelSettled(tunnelName);
const runtime = activeTunnelRuntimes.get(tunnelName);
if (!runtime) {
throw new Error("The endpoint tunnel closed before it could be used");
}
await probeEndpointTarget(runtime.sourceClient, endpoint.port, tunnelName);
openedTargets.set(tunnelName, fingerprint);
return res.status(200).json({ port: runtime.bindPort });
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
tunnelLogger.error("Failed to open web endpoint tunnel", error, {
operation: "web_endpoint_open_failed",
tunnelName,
});
// Leave nothing half-built: a listener with no working forward behind it
// would be handed out on the next open as a live tunnel.
await cleanupTunnelResources(tunnelName, true).catch(() => undefined);
openedTargets.delete(tunnelName);
return res.status(502).json({ error: reason });
}
}
export const router = express.Router();
let started = false;
export function startWebEndpointService(): void {
if (!started) {
// Built on first start rather than at module scope so importing this
// module for a handler unit test does not construct the auth singleton.
const authenticateJWT = AuthManager.getInstance().createAuthMiddleware();
// Only "/open" here: the dispatcher this router is registered with is
// mounted at /ssh/tunnel/web-endpoint, so the full path stays
// /ssh/tunnel/web-endpoint/open exactly as before. The "/ssh" prefix is
// part of the path the tunnel service serves -- nginx proxies /ssh/tunnel/
// through with the path intact.
router.post("/open", authenticateJWT, handleWebEndpointOpen);
started = true;
}
registerWebEndpointRouter(router);
}
export function stopWebEndpointService(): void {
unregisterWebEndpointRouter();
}
@@ -0,0 +1,227 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Globe, RotateCw } from "lucide-react";
import { toast } from "sonner";
import {
currentTunnelHost,
resolveWebEndpointUrl,
webEndpointRefusalReason,
type WebEndpointRefusalReason,
} from "./web-endpoint-url";
import {
allowInvalidCertificateForOrigin,
openWebEndpointExternally,
openWebEndpointTunnel,
requireNumericHostId,
} from "./web-endpoint-api";
import { copyToClipboard } from "@/lib/clipboard";
import { isElectron } from "@/lib/electron";
import { Button } from "@/components/button";
import type { Host } from "@/types/ui-types";
const REFUSAL_MESSAGES: Record<WebEndpointRefusalReason, string> = {
"loopback-bind-on-remote-backend": "webEndpoint.tunnelUnreachableFromBrowser",
"shares-session-cookie-with-termix": "webEndpoint.tunnelSharesSessionCookie",
"direct-shares-session-cookie": "webEndpoint.directSharesSessionCookie",
};
/**
* Renders one web endpoint in an iframe.
*
* Takes the whole host plus the endpoint's ID -- not the endpoint object --
* so a tab whose endpoint was deleted while it was open (or that never matched
* one, if the host's webUiConfig raced the tab's mount) can be detected here
* and shown a plain "no longer exists" message instead of throwing on
* endpoint.scheme.
*/
export function WebEndpointTab({
host,
endpointId,
}: {
host: Host;
endpointId?: string;
}) {
const { t } = useTranslation();
const endpoint = (host.webUiConfig?.endpoints ?? []).find(
(candidate) => candidate.id === endpointId,
);
const [url, setUrl] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
/**
* Bumped on every resolution that lands, and folded into the iframe's key
* alongside the URL. A direct endpoint's URL never changes, and a live
* tunnel returns the SAME port on every open -- so the URL alone is not
* enough to key on: React bails out of a same-value setState, the key would
* not change, and Reload would silently do nothing in the two most common
* cases.
*/
const [generation, setGeneration] = useState(0);
/**
* Guards two in-flight resolutions completing out of order -- a
* double-clicked Reload, or a manual reload racing the mount effect. Only
* the most recently started resolution may apply its result.
*/
const resolveGenerationRef = useRef(0);
const resolve = useCallback(async () => {
if (!endpoint) return;
const mine = ++resolveGenerationRef.current;
setError(null);
try {
// Checked BEFORE the open call, not after. A tunnel must not be opened
// from a browser when a loopback bind on a remote backend would leave
// nothing to connect to, or when the tunnel URL would land on the page's
// own host string. A DIRECT endpoint on the same host that serves Termix
// leaks the session cookie exactly the same way -- cookies ignore the
// port -- so it is refused here too, before any navigation, rather than
// framing the URL and leaking on the first request.
const refusal = webEndpointRefusalReason(endpoint, isElectron(), host.ip);
if (refusal) {
throw new Error(t(REFUSAL_MESSAGES[refusal]));
}
if (!("credentialless" in HTMLIFrameElement.prototype)) {
throw new Error(t("webEndpoint.isolationUnavailable"));
}
// Always re-resolved rather than reloading the frame: the backend closes
// an idle tunnel after ten minutes and re-binds a fresh kernel-assigned
// port, so a cached port may be gone.
const localPort =
endpoint.access === "tunnel"
? await openWebEndpointTunnel(
requireNumericHostId(host.id),
endpoint.id,
)
: undefined;
const resolved = resolveWebEndpointUrl({
hostAddress: host.ip,
endpoint,
localPort,
// Non-null for a tunnel by the time we get here: a null separated host
// is exactly what the refusal above catches.
tunnelHost: currentTunnelHost(isElectron()) ?? undefined,
});
if (endpoint.access === "direct" && endpoint.ignoreCert) {
await allowInvalidCertificateForOrigin(new URL(resolved).origin);
}
if (mine !== resolveGenerationRef.current) return;
setUrl(resolved);
setGeneration((g) => g + 1);
} catch (caught) {
if (mine !== resolveGenerationRef.current) return;
setError(caught instanceof Error ? caught.message : String(caught));
}
// Depends on the endpoint's FIELDS, not the object: host.webUiConfig is
// re-derived on every render of the host list, so a dependency on
// `endpoint` itself would reopen the tunnel on every parent render.
// bindHost and localPort are included because the refusal guard reads them.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
host.id,
host.ip,
endpoint?.id,
endpoint?.access,
endpoint?.scheme,
endpoint?.port,
endpoint?.path,
endpoint?.ignoreCert,
endpoint?.bindHost,
endpoint?.localPort,
]);
useEffect(() => {
void resolve();
}, [resolve]);
if (!endpoint) {
return (
<div className="flex h-full flex-col items-center justify-center gap-2 p-6 text-xs text-muted-foreground">
<Globe className="size-5 opacity-60" />
<p>{t("webEndpoint.notFound")}</p>
</div>
);
}
if (error) {
return (
<div className="flex h-full flex-col items-center justify-center gap-2 p-6 text-xs">
<Globe className="size-5 opacity-60" />
<p className="text-center">{error}</p>
<Button variant="outline" size="sm" onClick={() => void resolve()}>
{t("webEndpoint.retry")}
</Button>
</div>
);
}
return (
<div className="flex h-full flex-col">
<div className="flex items-center gap-2 border-b border-border px-2 py-1 text-xs">
<Button
variant="ghost"
size="icon-sm"
onClick={() => void resolve()}
title={t("webEndpoint.reload")}
>
<RotateCw />
</Button>
<span className="truncate text-muted-foreground">{url ?? ""}</span>
<Button
variant="ghost"
size="xs"
className="ml-auto"
onClick={() => {
if (!url) return;
void copyToClipboard(url).then((ok) => {
if (ok) toast.success(t("webEndpoint.urlCopied"));
else toast.error(t("webEndpoint.copyFailed"));
});
}}
>
{t("webEndpoint.copyUrl")}
</Button>
{isElectron() && (
<Button
variant="ghost"
size="xs"
onClick={() => {
void openWebEndpointExternally(host, endpoint).catch((error) =>
toast.error(error.message),
);
}}
>
{t("webEndpoint.openIsolatedWindow")}
</Button>
)}
</div>
<p className="px-2 py-1 text-xs text-muted-foreground">
{t("webEndpoint.isolationNotice")}
</p>
{url && (
// Keyed on generation as well as url: a re-resolved tunnel usually
// returns the SAME port, so url alone would not change and React would
// never remount the frame.
<iframe
key={`${generation}:${url}`}
title={endpoint.label}
ref={(frame) => {
if (!frame) return;
// Set isolation before the first navigation, including redirects.
(
frame as HTMLIFrameElement & { credentialless: boolean }
).credentialless = true;
frame.src = url;
}}
sandbox="allow-scripts allow-forms"
referrerPolicy="no-referrer"
className="h-full w-full flex-1 border-0"
/>
)}
</div>
);
}
@@ -0,0 +1,39 @@
/**
* Frontend half of the web-endpoint plugin.
*
* Registers the endpoint viewer tab through the existing registerTabComponent
* seam the same way network-topology's frontend does.
*
* HostEditorWebUiSection stays in src/ui/sidebar/ rather than moving here, and
* is not registered through registerHostEditorTab: it is a section inside the
* host editor's existing General tab, not a tab of its own, and it takes the
* same {form, setField} pair every other host editor field does -- those types
* (HostEditorForm, the setField signature) are core types HostEditor.tsx owns,
* the same reason docker leaves HostDockerTab in core rather than copying it
* into plugins/docker.
*
* register() is called when the plugin is enabled, unregister() when it is
* disabled. As of this change neither is actually invoked anywhere yet -- see
* src/ui/shell/pluginLoader.ts and
* src/ui/tests/sidebar/plugin-extension-seam.test.tsx, which says outright
* "there is no plugin loader yet" for the frontend half. Until one exists the
* tab is rendered directly by tabUtils.tsx and merely gated on the plugin's
* enabled state through BUILT_IN_TABS_BY_PLUGIN. This file follows the same
* shape every other plugin's frontend entry does so it is ready the moment a
* real loader lands.
*/
export const id = "web-endpoint";
export const tabId = "web-endpoint";
export async function register({ registerTabComponent }) {
registerTabComponent?.(tabId, () =>
import("./WebEndpointTab.tsx").then((m) => ({
default: m.WebEndpointTab,
})),
);
}
export async function unregister({ unregisterTabComponent }) {
unregisterTabComponent?.(tabId);
}
@@ -0,0 +1,136 @@
import { currentTunnelHost, resolveWebEndpointUrl } from "./web-endpoint-url";
import axios from "axios";
import { handleApiError, tunnelApi } from "@/main-axios";
import { isElectron } from "@/lib/electron";
import type { WebEndpoint } from "@/types/index";
/**
* Thrown when the backend rejects a web endpoint tunnel open with a specific,
* actionable reason -- most commonly a 502 carrying the real cause: SSH auth
* rejected, host unreachable, or nothing listening on the target port.
* Preserves that reason instead of letting handleApiError collapse three
* actionable errors into "Server error occurred. Please try again later."
*/
export class WebEndpointTunnelError extends Error {
constructor(
message: string,
public readonly status?: number,
) {
super(message);
this.name = "WebEndpointTunnelError";
}
}
/**
* Opens (or reuses) the SSH forward for a tunnel-access web endpoint and
* returns the port it is listening on. The port is NOT stable across opens:
* the backend closes an idle tunnel and re-binds a fresh kernel-assigned port,
* so callers must re-resolve rather than cache it.
*/
export async function openWebEndpointTunnel(
hostId: number,
endpointId: string,
): Promise<number> {
try {
// Relative to the tunnel API base, which already includes /ssh
// (getApiUrl("/ssh", 30003)). A path beginning "/ssh" here would resolve
// to /ssh/ssh/... and 404 on every call.
const response = await tunnelApi.post("/tunnel/web-endpoint/open", {
hostId,
endpointId,
});
const port = (response?.data as { port?: number } | undefined)?.port;
if (!port) {
throw new Error("The endpoint tunnel returned no port");
}
return port;
} catch (error) {
if (axios.isAxiosError(error)) {
// Typed unknown, not string: the guard below is only meaningful if the
// type does not already promise what it checks.
const data = error.response?.data as
{ error?: unknown; message?: unknown } | undefined;
const backendMessage = data?.error ?? data?.message;
// A body shaped { error: <object> } would otherwise render as
// "[object Object]". Every response on this route sends a string today,
// but a proxy or a future handler need not.
if (typeof backendMessage === "string" && backendMessage) {
throw new WebEndpointTunnelError(
backendMessage,
error.response?.status,
);
}
}
return handleApiError(error, "open web endpoint tunnel");
}
}
/**
* Registers one exact origin as allowed to present an invalid TLS certificate.
* Only meaningful on the desktop, and only for direct endpoints -- a tunnel
* endpoint's host component is loopback, which the main process already
* exempts.
*/
export async function allowInvalidCertificateForOrigin(
origin: string,
): Promise<void> {
if (!isElectron()) return;
try {
await window.electronAPI?.invoke?.(
"allow-invalid-certificate-for-origin",
origin,
);
} catch {
// The user's build may predate the handler; the load then simply fails the
// certificate check as it would have before.
}
}
/**
* A web endpoint tunnel opens through the backend's numeric host id.
* Quick-connect hosts (ids like "quick-connect-<n>") have no row on the
* server, so there is nothing to open a tunnel through -- unlike a direct
* endpoint, which never touches the backend and works regardless.
*/
export function requireNumericHostId(id: string): number {
const numericId = Number(id);
if (!Number.isInteger(numericId) || numericId <= 0) {
throw new Error(
"This endpoint needs an SSH tunnel, which requires a saved host",
);
}
return numericId;
}
/** Desktop windows use a dedicated ephemeral session, including login popups. */
export async function openWebEndpointExternally(
host: { id: string; ip: string },
endpoint: WebEndpoint,
): Promise<void> {
if (!isElectron() || !window.electronAPI?.invoke) {
throw new Error(
"Isolated windows require the desktop app. Choose Embedded in the endpoint settings.",
);
}
const localPort =
endpoint.access === "tunnel"
? await openWebEndpointTunnel(requireNumericHostId(host.id), endpoint.id)
: undefined;
const url = resolveWebEndpointUrl({
hostAddress: host.ip,
endpoint,
localPort,
tunnelHost: currentTunnelHost(true) ?? undefined,
});
const result = await window.electronAPI.invoke("open-isolated-web-endpoint", {
url,
ignoreCert: endpoint.ignoreCert === true,
});
if (
!result ||
typeof result !== "object" ||
!("success" in result) ||
result.success !== true
)
throw new Error("Failed to open isolated web endpoint");
}
@@ -0,0 +1,211 @@
import type { WebEndpoint } from "@/types/index";
/**
* Builds the URL a web endpoint opens at.
*
* Direct access points at the host's own address. Tunnel access points at a
* port the backend has forwarded to the endpoint's port on the host -- so the
* endpoint's own port is deliberately absent from a tunnel URL.
*
* Expects an endpoint whose `path` has already been normalized by
* `normalizeWebEndpoints` (it always begins with "/"); a raw unvalidated path
* would produce a malformed URL.
*/
export function resolveWebEndpointUrl({
hostAddress,
endpoint,
localPort,
tunnelHost,
}: {
hostAddress: string;
endpoint: WebEndpoint;
localPort?: number;
/**
* Where the caller can reach the forward the backend bound. Must never be
* the host string the browser is currently reaching Termix at -- see
* `separatedTunnelHost`. Defaults to loopback.
*/
tunnelHost?: string;
}): string {
const path = endpoint.path && endpoint.path.length > 0 ? endpoint.path : "/";
if (endpoint.access === "tunnel") {
if (!localPort) {
throw new Error("A tunnel endpoint needs a local port to resolve a URL");
}
const authority = bracketIfIpv6(tunnelHost?.trim() || "127.0.0.1");
return `${endpoint.scheme}://${authority}:${localPort}${path}`;
}
return `${endpoint.scheme}://${bracketIfIpv6(hostAddress)}:${endpoint.port}${path}`;
}
/** A bare IPv6 literal has to be bracketed to be a legal URL authority. */
function bracketIfIpv6(host: string): string {
return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
}
/** Host spellings that mean "this machine". */
const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]);
/**
* Each loopback spelling mapped to a DIFFERENT one that reaches the same
* machine. Being a different string is the entire point.
*/
const LOOPBACK_ALIASES: Record<string, string> = {
localhost: "127.0.0.1",
"127.0.0.1": "localhost",
"::1": "127.0.0.1",
"[::1]": "127.0.0.1",
};
/**
* A host string that reaches the same machine as `pageHost` but is a
* different key in the browser's cookie jar -- or null when none can be
* derived.
*
* This is a security mitigation, not a cosmetic choice. Cookies are keyed by
* host and IGNORE the port, and SameSite computes "site" as scheme +
* registrable domain -- also ignoring the port. So a forward reached at the
* very host string serving Termix is same-site with Termix, and two things
* follow:
*
* 1. the browser attaches Termix's `jwt` cookie to every request the framed
* third-party server sees, including its access log; and
* 2. a `Set-Cookie: jwt=...` from that server lands in the jar Termix's own
* API calls read from -- and both auth middlewares read the cookie
* BEFORE the Authorization header, so it decides who Termix acts as.
*
* Both directions were reproduced against a real SSH forward.
*
* Only loopback literals get an alias. A same-registrable-domain name would
* not be safe even though it is a different host: the target could answer
* `Set-Cookie: jwt=...; Domain=example.com`, which reaches Termix anyway.
* Loopback literals cannot carry a Domain attribute, so they are the only
* spellings that close both directions.
*/
export function separatedTunnelHost(pageHost: string): string | null {
return LOOPBACK_ALIASES[pageHost.trim().toLowerCase()] ?? null;
}
/** Lowercase, trim, drop surrounding IPv6 brackets and a single trailing dot. */
function normalizeHost(host: string): string {
let h = host.trim().toLowerCase();
if (h.startsWith("[") && h.endsWith("]")) h = h.slice(1, -1);
if (h.endsWith(".")) h = h.slice(0, -1);
return h;
}
/**
* Whether a browser would attach Termix's `jwt` cookie to a request for
* `targetHost` while the page is served from `pageHost` -- or accept a
* `Set-Cookie` from `targetHost` into the jar Termix's own API reads.
*
* The web client sets `jwt` host-only (no `Domain`), so the port is irrelevant
* and the disclosure direction is exactly an equal host. The reverse direction
* -- the target answering `Set-Cookie: jwt=...; Domain=<parent>` -- reaches
* Termix whenever the two share a domain, so a parent/sub relationship counts
* too. The suffix check is anchored on a dot boundary so `eviltermix.example`
* is not treated as same-site with `termix.example`.
*
* A precise registrable-domain (eTLD+1) test would also catch sibling
* subdomains under a shared parent, but needs a public-suffix list this app
* does not bundle; the exact/parent/sub check closes the reported same-host
* case only. This is defense in depth, not cookie isolation: embedded frames
* must use credentialless plus an opaque sandbox origin, including redirects.
*/
export function sharesCookieSiteWithPage(
targetHost: string,
pageHost: string,
): boolean {
const t = normalizeHost(targetHost);
const p = normalizeHost(pageHost);
if (!t || !p) return false;
if (t === p) return true;
return t.endsWith("." + p) || p.endsWith("." + t);
}
function defaultPageHost(): string {
return typeof window === "undefined" ? "127.0.0.1" : window.location.hostname;
}
/**
* Why a web endpoint must not be opened from this client.
*
* `loopback-bind-on-remote-backend` -- the forward binds where the backend
* runs. In a browser that is the server, so a loopback bind answers only to
* the server itself: the open succeeds, the port exists, and the browser
* still gets nothing.
*
* `shares-session-cookie-with-termix` -- the tunnel URL would resolve to the
* same host string the page is served from, handing Termix's session token to
* the tunnelled service. See `separatedTunnelHost`.
*
* `direct-shares-session-cookie` -- the same leak without a tunnel: a direct
* endpoint's target host shares Termix's cookie site, so the browser attaches
* the `jwt` to it (and accepts one back). A different port is not a different
* cookie key. See `sharesCookieSiteWithPage`.
*/
export type WebEndpointRefusalReason =
| "loopback-bind-on-remote-backend"
| "shares-session-cookie-with-termix"
| "direct-shares-session-cookie";
/**
* Why a web endpoint must not be opened from this client, or null when it may.
*
* Covers both access modes. A tunnel is refused when its forward would either
* be unreachable (loopback bind on a remote backend) or resolve to the page's
* own host string (`shares-session-cookie-with-termix`). A DIRECT endpoint is
* refused when its target host shares Termix's cookie site
* (`direct-shares-session-cookie`) -- the same leak, reached without a tunnel:
* cookies ignore the port, so a UI on the very host serving Termix receives
* the session. `hostAddress` is the direct endpoint's target host and is
* ignored for tunnels.
*
* The desktop is exempt: its session is Bearer-only with no `jwt` in the jar,
* and its API base ("localhost") is already separated from where tunnels
* resolve ("127.0.0.1").
*/
export function webEndpointRefusalReason(
endpoint: Pick<WebEndpoint, "access" | "bindHost">,
runningInElectron: boolean,
hostAddress: string | undefined,
pageHost: string = defaultPageHost(),
): WebEndpointRefusalReason | null {
if (runningInElectron) return null;
if (endpoint.access === "direct") {
if (hostAddress && sharesCookieSiteWithPage(hostAddress, pageHost)) {
return "direct-shares-session-cookie";
}
return null;
}
if (endpoint.access !== "tunnel") return null;
const bindHost = (endpoint.bindHost ?? "").trim().toLowerCase();
// Reported first when both apply: the bind address is what the user has to
// change either way, and it is the more specific instruction.
if (!bindHost || LOOPBACK_HOSTS.has(bindHost)) {
return "loopback-bind-on-remote-backend";
}
if (!separatedTunnelHost(pageHost)) {
return "shares-session-cookie-with-termix";
}
return null;
}
/**
* Where this client should reach a forward the backend bound, or null when no
* host string is both reachable and safe -- in which case
* `unreachableTunnelReason` explains why and the caller must not open.
*/
export function currentTunnelHost(
runningInElectron: boolean,
pageHost: string = defaultPageHost(),
): string | null {
if (runningInElectron) return "127.0.0.1";
return separatedTunnelHost(pageHost);
}
@@ -0,0 +1,121 @@
import type { WebEndpoint } from "@/types/index";
/**
* Editor-side validation for web endpoint rows.
*
* The authority on what a stored endpoint may look like is
* `normalizeWebEndpoints` in
* src/backend/database/routes/host-web-endpoints.ts, and it DROPS any row it
* refuses rather than reporting it -- so without a check here the user adds a
* row, saves, sees no error, and finds the endpoint gone on reload.
*
* This deliberately does NOT restate that module's full rule set, and does not
* import it: the renderer does not import backend route modules (the same
* reason MAX_WEB_ENDPOINTS lives in src/types rather than beside the
* validator). It covers only the conditions a user can reach by typing.
* src/ui/tests/lib/web-endpoint-validation.test.ts runs both implementations
* over the same samples so the two cannot drift apart silently.
*/
export const MIN_WEB_ENDPOINT_PORT = 1;
export const MAX_WEB_ENDPOINT_PORT = 65535;
export type WebEndpointFieldError =
"label" | "duplicateLabel" | "port" | "path";
const ERROR_KEYS: Record<WebEndpointFieldError, string> = {
label: "hosts.webUiErrorLabelRequired",
duplicateLabel: "hosts.webUiErrorLabelDuplicate",
port: "hosts.webUiErrorPortRange",
path: "hosts.webUiErrorPathInvalid",
};
/**
* The i18n key for a field error. Every key takes a `row` interpolation so the
* message can say which endpoint is at fault.
*/
export function webEndpointErrorKey(error: WebEndpointFieldError): string {
return ERROR_KEYS[error];
}
export function isWebEndpointPortValid(port: unknown): boolean {
return (
typeof port === "number" &&
Number.isInteger(port) &&
port >= MIN_WEB_ENDPOINT_PORT &&
port <= MAX_WEB_ENDPOINT_PORT
);
}
function hasControlCharacter(value: string): boolean {
for (let index = 0; index < value.length; index += 1) {
const code = value.charCodeAt(index);
if (code <= 0x1f || code === 0x7f) return true;
}
return false;
}
/**
* True if the normalizer would accept this path. An empty or absent path is
* fine -- the normalizer defaults it to "/" -- and so is a path with no
* leading slash, which the normalizer coerces.
*/
export function isWebEndpointPathValid(
raw: string | undefined | null,
): boolean {
if (raw === undefined || raw === null || raw === "") return true;
if (hasControlCharacter(raw)) return false;
const path = raw.startsWith("/") ? raw : `/${raw}`;
// A protocol-relative or absolute path would send the iframe or the link
// somewhere other than the endpoint.
if (path.startsWith("//")) return false;
if (/^\/[a-z][a-z0-9+.-]*:/i.test(path)) return false;
if (path.includes("\\")) return false;
return true;
}
/**
* The first thing wrong with one row, or null if the normalizer would keep it.
* Label is checked before port so the message points at the field the user
* most likely just left blank.
*/
export function webEndpointRowError(
endpoint: WebEndpoint,
): WebEndpointFieldError | null {
if (typeof endpoint.label !== "string" || endpoint.label.trim() === "") {
return "label";
}
if (!isWebEndpointPortValid(endpoint.port)) return "port";
if (!isWebEndpointPathValid(endpoint.path)) return "path";
return null;
}
/**
* The first row the normalizer would drop, so a save can be blocked with a
* message naming it. Returns null when every row would survive.
*
* Duplicate LABELS are reported even though the normalizer tolerates them:
* the sidebar picker shows labels, so two identical ones are indistinguishable
* to the user. Duplicate ids are generated, never typed.
*/
export function findInvalidWebEndpoint(
endpoints: WebEndpoint[] | undefined | null,
): { index: number; error: WebEndpointFieldError } | null {
if (!Array.isArray(endpoints)) return null;
const seen = new Set<string>();
for (let index = 0; index < endpoints.length; index += 1) {
const endpoint = endpoints[index];
const error = webEndpointRowError(endpoint);
if (error) return { index, error };
const label = endpoint.label.trim().toLowerCase();
if (seen.has(label)) return { index, error: "duplicateLabel" };
seen.add(label);
}
return null;
}