import type { FastifyInstance, FastifyReply } from "fastify"; import { machineScopedPluginId, parseMachineScopedPluginId, type MachineScopedPluginIdParts } from "../../shared/machinePluginIds.js"; import { isPiWebPluginId } from "../../shared/pluginIds.js"; import { RemoteMachineRequestError, type MachineClient } from "./machineClient.js"; import { MachineService } from "./machineService.js"; interface RemotePluginManifestEntry { id: string; module: string; source?: string; scope?: string; machineSpecific?: boolean; } interface RemotePluginManifest { plugins: RemotePluginManifestEntry[]; } interface MachinePluginProxyMachines { remoteClient(id: string): Promise; } const MACHINE_PLUGIN_MANIFEST_TIMEOUT_MS = 10_000; const SAFE_RESPONSE_HEADERS = new Set([ "content-type", "content-length", "cache-control", "last-modified", "etag", "content-security-policy", "x-content-type-options", ]); export function registerMachinePluginProxyRoutes(app: FastifyInstance, machines: MachinePluginProxyMachines = new MachineService()): void { app.get<{ Params: { machineId: string } }>("/api/machines/:machineId/pi-web-plugins/manifest.json", async (request, reply) => { if (request.params.machineId === "local") return { plugins: [] }; const client = await machines.remoteClient(request.params.machineId); if (client === undefined) return reply.code(404).send({ error: "Machine not found" }); try { const response = await client.requestJson("GET", "/pi-web-plugins/manifest.json", undefined, { timeoutMs: MACHINE_PLUGIN_MANIFEST_TIMEOUT_MS }); if (response.statusCode === 404) return { plugins: [] }; if (response.statusCode < 200 || response.statusCode >= 300) return await reply.code(response.statusCode).send(response.body); return rewriteRemotePluginManifest(request.params.machineId, parseRemoteManifest(response.body)); } catch (error) { return sendGatewayError(reply, request.params.machineId, error); } }); } export async function proxyMachinePluginAsset(machines: MachinePluginProxyMachines, scopedPluginId: string, assetPath: string, requestUrl: string, reply: FastifyReply): Promise { const remotePlugin = parseMachineScopedPluginId(scopedPluginId); if (remotePlugin === undefined) return false; const client = await machines.remoteClient(remotePlugin.machineId); if (client === undefined) { await reply.code(404).send({ error: "Machine not found" }); return true; } const requestPath = remotePluginAssetRequestPath(remotePlugin, assetPath, requestUrl); if (requestPath === undefined) { await reply.code(400).send({ error: "Invalid remote PI WEB plugin asset path" }); return true; } try { const upstream = await client.request("GET", requestPath); reply.code(upstream.statusCode); applySafeHeaders(reply, upstream.headers); if (upstream.body === undefined) await reply.send(); else await reply.send(upstream.body); return true; } catch (error) { sendGatewayError(reply, remotePlugin.machineId, error); return true; } } function rewriteRemotePluginManifest(machineId: string, manifest: RemotePluginManifest): RemotePluginManifest { return { plugins: manifest.plugins.flatMap((plugin) => { const modulePath = remotePluginModulePath(plugin.id, plugin.module); if (modulePath === undefined) return []; return [{ ...plugin, module: `/pi-web-plugins/${encodeURIComponent(machineScopedPluginId(machineId, plugin.id))}/${modulePath.path}${modulePath.query}`, }]; }), }; } function remotePluginModulePath(pluginId: string, module: string): { path: string; query: string } | undefined { if (!isPiWebPluginId(pluginId)) return undefined; const prefix = `/pi-web-plugins/${encodeURIComponent(pluginId)}/`; const base = new URL(prefix, "http://pi-web.local"); try { const url = new URL(module, base); if (url.origin !== base.origin || !url.pathname.startsWith(prefix)) return undefined; const path = safeRemotePluginAssetPath(url.pathname.slice(prefix.length)); return path === undefined ? undefined : { path, query: url.search }; } catch { return undefined; } } function remotePluginAssetRequestPath(remotePlugin: MachineScopedPluginIdParts, assetPath: string, requestUrl: string): string | undefined { const path = safeRemotePluginAssetPath(assetPath); if (path === undefined) return undefined; const query = requestUrl.includes("?") ? requestUrl.slice(requestUrl.indexOf("?")) : ""; return `/pi-web-plugins/${encodeURIComponent(remotePlugin.pluginId)}/${path}${query}`; } function safeRemotePluginAssetPath(path: string): string | undefined { const segments: string[] = []; for (const rawSegment of path.split("/")) { const segment = safeRemotePluginAssetPathSegment(rawSegment); if (segment === undefined) return undefined; if (segment === "") continue; segments.push(segment); } if (segments.length === 0) return undefined; return segments.map((segment) => encodeURIComponent(segment)).join("/"); } function safeRemotePluginAssetPathSegment(rawSegment: string): string | undefined { if (rawSegment === "" || rawSegment === ".") return ""; if (/%(?:2f|5c)/iu.test(rawSegment)) return undefined; let segment: string; try { segment = decodeURIComponent(rawSegment); } catch { return undefined; } if (segment === "" || segment === ".") return ""; if (segment === ".." || segment.includes("/") || segment.includes("\\") || hasControlCharacter(segment)) return undefined; return segment; } 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; } function parseRemoteManifest(value: unknown): RemotePluginManifest { if (!isRecord(value) || !Array.isArray(value["plugins"])) throw new Error("Invalid remote PI WEB plugin manifest"); return { plugins: value["plugins"].map((entry) => { if (!isRecord(entry) || typeof entry["id"] !== "string" || !isPiWebPluginId(entry["id"]) || typeof entry["module"] !== "string" || entry["module"] === "") { throw new Error("Invalid remote PI WEB plugin manifest entry"); } return { id: entry["id"], module: entry["module"], ...(typeof entry["source"] === "string" ? { source: entry["source"] } : {}), ...(typeof entry["scope"] === "string" ? { scope: entry["scope"] } : {}), ...(parseRemoteMachineSpecific(entry["machineSpecific"])), }; }), }; } function parseRemoteMachineSpecific(value: unknown): { machineSpecific?: boolean } { if (value === undefined) return {}; if (typeof value !== "boolean") throw new Error("Invalid remote PI WEB plugin manifest entry"); return { machineSpecific: value }; } function applySafeHeaders(reply: FastifyReply, headers: Record): void { for (const [name, value] of Object.entries(headers)) { if (value === undefined) continue; if (!SAFE_RESPONSE_HEADERS.has(name.toLowerCase())) continue; reply.header(name, value); } } function sendGatewayError(reply: FastifyReply, machineId: string, error: unknown): FastifyReply { const statusCode = error instanceof RemoteMachineRequestError ? error.statusCode : 502; const label = statusCode === 504 ? "Remote machine timeout" : "Remote machine unavailable"; return reply.code(statusCode).send({ error: label, machineId, statusCode, detail: error instanceof Error ? error.message : String(error), }); } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); }