import type { FastifyInstance, FastifyReply } from "fastify"; import type { WebSocket } from "ws"; import { FEDERATED_HTTP_ROUTES, FEDERATED_WEBSOCKET_ROUTES } from "../../shared/federatedRoutes.js"; import { bridgeSockets } from "../webSocketBridge.js"; import { RemoteMachineRequestError } from "./machineClient.js"; import { MachineService } from "./machineService.js"; export const REMOTE_HTTP_ROUTES = FEDERATED_HTTP_ROUTES; export const REMOTE_WEBSOCKET_ROUTES = FEDERATED_WEBSOCKET_ROUTES; const SAFE_RESPONSE_HEADERS = new Set([ "content-type", "content-length", "cache-control", "last-modified", "etag", "content-security-policy", "x-content-type-options", ]); export function registerMachineProxyRoutes(app: FastifyInstance, machines = new MachineService()): void { for (const spec of REMOTE_HTTP_ROUTES) { app.route<{ Params: { machineId: string }; Body: unknown }>({ method: spec.method, url: `/api/machines/:machineId${spec.path}`, handler: (request, reply) => proxyHttpRequest(machines, request.params.machineId, request.method, request.url, request.body, reply), }); } for (const path of REMOTE_WEBSOCKET_ROUTES) { app.get<{ Params: { machineId: string } }>(`/api/machines/:machineId${path}`, { websocket: true }, async (socket, request) => { await proxyWebSocket(machines, request.params.machineId, request.url, socket); }); } } async function proxyHttpRequest(machines: MachineService, machineId: string, method: string, requestUrl: string, body: unknown, reply: FastifyReply): Promise { if (machineId === "local") { return reply.code(501).send({ error: "Local machine route is not registered for this endpoint" }); } const client = await machines.remoteClient(machineId); if (client === undefined) { return reply.code(404).send({ error: "Machine not found" }); } try { const upstream = await client.request(method, remoteApiPath(machineId, requestUrl), body); reply.code(upstream.statusCode); applySafeHeaders(reply, upstream.headers); if (upstream.body === undefined) return await reply.send(); return await reply.send(upstream.body); } catch (error) { return sendGatewayError(reply, machineId, error); } } async function proxyWebSocket(machines: MachineService, machineId: string, requestUrl: string, socket: WebSocket): Promise { if (machineId === "local") { socket.close(1011, "Local machine route is not registered for this endpoint"); return; } const client = await machines.remoteClient(machineId); if (client === undefined) { socket.close(1008, "Machine not found"); return; } try { bridgeSockets(socket, client.connectWebSocket(remoteApiPath(machineId, requestUrl))); } catch { socket.close(1011, "Remote machine unavailable"); } } function remoteApiPath(machineId: string, requestUrl: string): string { const machinePrefix = `/api/machines/${encodeURIComponent(machineId)}`; const stripped = requestUrl.startsWith(machinePrefix) ? requestUrl.slice(machinePrefix.length) : requestUrl; const compatPath = stripped.startsWith("/") ? stripped : `/${stripped}`; return `/api${compatPath}`; } 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), }); }