277 lines · 9.5 KB · TypeScript
RawDownload
import { timingSafeEqual } from "node:crypto";import { Hono } from "hono";import { cors } from "hono/cors";import { loadDotEnvironment } from "./config/env"; /** * Passthrough proxy mode: a lightweight controller that owns no engine, GPUs, * or local stores and transparently forwards every request to an upstream * controller. Activated by LOCAL_STUDIO_UPSTREAM_URL. It deliberately skips * AppContext (SQLite stores, process/download managers, metrics collector, * models_dir requirement) so it can run on machines with no models or GPUs. * * Local behavior in proxy mode: * - `/proxy/health` reports the proxy process itself plus upstream reachability. * - Everything else — including `/health` — is forwarded, so clients observe *   the upstream's real availability instead of a false local "ok". * - Inbound auth uses LOCAL_STUDIO_API_KEY (same rule as the full controller: *   required on non-loopback binds); the upstream key is attached on the way *   out and never exposed to local clients. * - Rate limiting and request observability are intentionally not applied: *   the upstream already enforces both, and double-counting would skew its *   accounting. */export interface ProxyModeConfig {  host: string;  port: number;  api_key?: string;  upstream_url: string;  upstream_api_key?: string;  cors_origins: string[];} /** RFC 9110 connection-scoped headers that must not be forwarded either way. */const HOP_BY_HOP_HEADERS = [  "connection",  "keep-alive",  "proxy-authenticate",  "proxy-authorization",  "te",  "trailer",  "transfer-encoding",  "upgrade",]; const PUBLIC_PROXY_PATHS = new Set<string>(["/health", "/proxy/health"]); const DEFAULT_CORS_ORIGINS = [  "http://localhost:3000",  "http://127.0.0.1:3000",  "http://localhost:3001",  "http://127.0.0.1:3001",  "http://host.docker.internal:3000",  "http://host.docker.internal:3001",]; const isLoopbackHost = (value: string): boolean => {  const normalized = value.trim().toLowerCase();  return normalized === "127.0.0.1" || normalized === "localhost" || normalized === "::1";}; const parseOrigins = (value: string | undefined): string[] => {  const candidates =    value && value.trim().length > 0      ? value.split(",").map((entry) => entry.trim())      : DEFAULT_CORS_ORIGINS;  const origins = candidates.flatMap((entry) => {    try {      const origin = new URL(entry).origin;      return origin === "null" ? [] : [origin];    } catch {      return [];    }  });  return [...new Set(origins)];}; export const isProxyModeEnabled = (): boolean =>  Boolean(process.env["LOCAL_STUDIO_UPSTREAM_URL"]?.trim()); export const createProxyModeConfig = (): ProxyModeConfig => {  loadDotEnvironment();   const upstreamRaw = process.env["LOCAL_STUDIO_UPSTREAM_URL"]?.trim();  if (!upstreamRaw) {    throw new Error("LOCAL_STUDIO_UPSTREAM_URL is required to start the controller in proxy mode");  }  let upstream: URL;  try {    upstream = new URL(upstreamRaw);  } catch {    throw new Error(`LOCAL_STUDIO_UPSTREAM_URL is not a valid URL: ${upstreamRaw}`);  }  if (upstream.protocol !== "http:" && upstream.protocol !== "https:") {    throw new Error(`LOCAL_STUDIO_UPSTREAM_URL must be http(s), got ${upstream.protocol}`);  }   const host = process.env["LOCAL_STUDIO_HOST"]?.trim() || "127.0.0.1";  const portRaw = process.env["LOCAL_STUDIO_PORT"];  const port = portRaw === undefined ? 8080 : Number(portRaw);  if (!Number.isInteger(port) || port <= 0) {    throw new Error(`LOCAL_STUDIO_PORT must be a positive integer, got ${portRaw}`);  }   const config: ProxyModeConfig = {    host,    port,    upstream_url: upstream.origin + (upstream.pathname === "/" ? "" : upstream.pathname),    cors_origins: parseOrigins(process.env["LOCAL_STUDIO_CORS_ORIGINS"]),  };   const apiKey = process.env["LOCAL_STUDIO_API_KEY"]?.trim();  if (apiKey) config.api_key = apiKey;  const upstreamApiKey = process.env["LOCAL_STUDIO_UPSTREAM_API_KEY"]?.trim();  if (upstreamApiKey) config.upstream_api_key = upstreamApiKey;   const allowUnauthenticated =    process.env["LOCAL_STUDIO_ALLOW_UNAUTHENTICATED"]?.trim().toLowerCase() === "true";  if (!config.api_key && !allowUnauthenticated && !isLoopbackHost(host)) {    throw new Error(      "LOCAL_STUDIO_API_KEY is required when binding the proxy controller to a non-loopback host. Set LOCAL_STUDIO_ALLOW_UNAUTHENTICATED=true only for trusted local environments.",    );  }   return config;}; export const buildUpstreamUrl = (upstreamBase: string, requestUrl: string): URL => {  const inbound = new URL(requestUrl);  const base = new URL(upstreamBase);  const basePath = base.pathname.endsWith("/") ? base.pathname.slice(0, -1) : base.pathname;  const target = new URL(base.origin);  target.pathname = `${basePath}${inbound.pathname}`;  target.search = inbound.search;  return target;}; export const buildForwardHeaders = (  requestHeaders: Headers,  upstreamApiKey: string | undefined,): Headers => {  const headers = new Headers(requestHeaders);  headers.delete("host");  headers.delete("authorization");  headers.delete("x-api-key");  // The body is re-streamed, so any inbound framing headers are stale.  headers.delete("content-length");  for (const name of HOP_BY_HOP_HEADERS) headers.delete(name);  if (upstreamApiKey) headers.set("authorization", `Bearer ${upstreamApiKey}`);  return headers;}; export const buildResponseHeaders = (upstreamHeaders: Headers): Headers => {  const headers = new Headers(upstreamHeaders);  for (const name of HOP_BY_HOP_HEADERS) headers.delete(name);  // fetch already decoded the body; the original encoding/length no longer apply.  headers.delete("content-encoding");  headers.delete("content-length");  return headers;}; const extractInboundToken = (headers: Headers): string | null => {  const bearer = headers.get("authorization");  if (bearer) {    const match = bearer.match(/^Bearer\s+(.+)$/i);    if (match?.[1]) return match[1].trim();  }  return headers.get("x-api-key")?.trim() || null;}; const inboundTokenMatches = (expected: string, provided: string | null): boolean => {  if (!provided) return false;  const expectedBuffer = Buffer.from(expected);  const providedBuffer = Buffer.from(provided);  return (    expectedBuffer.length === providedBuffer.length &&    timingSafeEqual(expectedBuffer, providedBuffer)  );}; const passthrough = async (request: Request, config: ProxyModeConfig): Promise<Response> => {  const target = buildUpstreamUrl(config.upstream_url, request.url);  const init: RequestInit & { duplex?: "half" } = {    method: request.method,    headers: buildForwardHeaders(request.headers, config.upstream_api_key),    redirect: "manual",    signal: request.signal,  };  if (request.method !== "GET" && request.method !== "HEAD" && request.body) {    // Stream the body through untouched — audio uploads and model downloads    // are far too large to buffer.    init.body = request.body;    init.duplex = "half";  }  const upstream = await fetch(target, init);  return new Response(upstream.body, {    status: upstream.status,    statusText: upstream.statusText,    headers: buildResponseHeaders(upstream.headers),  });}; export const createProxyApp = (config: ProxyModeConfig): Hono => {  const app = new Hono();   app.use(    "*",    cors({      origin: (origin) => (config.cors_origins.includes(origin) ? origin : null),      allowMethods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],      allowHeaders: ["Authorization", "Content-Type", "X-API-Key"],      maxAge: 600,    }),  );   app.use("*", async (ctx, next) => {    if (ctx.req.method === "OPTIONS" || PUBLIC_PROXY_PATHS.has(ctx.req.path)) return next();    const expected = config.api_key;    if (!expected) return next();    if (inboundTokenMatches(expected, extractInboundToken(ctx.req.raw.headers))) return next();    ctx.header("WWW-Authenticate", 'Bearer realm="local-studio-controller"');    return ctx.json({ detail: "Unauthorized" }, { status: 401 });  });   app.get("/proxy/health", async (ctx) => {    let upstreamHealth = "unreachable";    try {      const response = await fetch(`${config.upstream_url}/health`, {        signal: AbortSignal.timeout(3000),      });      upstreamHealth = response.ok ? "ok" : `http_${response.status}`;    } catch {      // upstream unreachable; reported below    }    return ctx.json({      status: "ok",      mode: "proxy",      upstream: config.upstream_url,      upstream_health: upstreamHealth,    });  });   app.all("*", async (ctx) => {    try {      return await passthrough(ctx.req.raw, config);    } catch (error) {      const message = String(error);      if (message.includes("AbortError") || message.includes("aborted")) {        return new Response(null, { status: 499 });      }      return ctx.json({ detail: `Upstream controller unreachable: ${message}` }, { status: 502 });    }  });   return app;}; export const startProxyMode = (): ReturnType<typeof Bun.serve> => {  const config = createProxyModeConfig();  const app = createProxyApp(config);  const server = Bun.serve({    port: config.port,    hostname: config.host,    fetch: app.fetch,    idleTimeout: 120,  });  const authMode = config.api_key ? "api-key" : "unauthenticated (no LOCAL_STUDIO_API_KEY)";  console.log(    [      "Controller in passthrough proxy mode:",      `listen=${config.host}:${server.port}`,      `upstream=${config.upstream_url}`,      `upstream_auth=${config.upstream_api_key ? "api-key" : "none"}`,      `auth=${authMode}`,    ].join(" "),  );  return server;};