mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45: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
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user