331 lines · 11.4 KB · TypeScript
RawDownload
// CRITICALimport { NextRequest, NextResponse } from "next/server";import { getApiSettings } from "@/lib/api-settings";import { getUpstreamTimeoutMs } from "./proxy-timeouts"; const OVERRIDE_ALLOWLIST_ENV_KEY = "VLLM_STUDIO_PROXY_OVERRIDE_ALLOWLIST";const PROXY_ACCESS_LOGS_ENABLED = process.env.VLLM_STUDIO_PROXY_ACCESS_LOGS === "true"; export async function GET(  request: NextRequest,  { params }: { params: Promise<{ path: string[] }> },) {  const { path } = await params;  return handleRequest(request, "GET", path);} export async function POST(  request: NextRequest,  { params }: { params: Promise<{ path: string[] }> },) {  const { path } = await params;  return handleRequest(request, "POST", path);} export async function PUT(  request: NextRequest,  { params }: { params: Promise<{ path: string[] }> },) {  const { path } = await params;  return handleRequest(request, "PUT", path);} export async function DELETE(  request: NextRequest,  { params }: { params: Promise<{ path: string[] }> },) {  const { path } = await params;  return handleRequest(request, "DELETE", path);} function getClientInfo(request: NextRequest) {  const ip =    request.headers.get("CF-Connecting-IP") ||    request.headers.get("X-Forwarded-For")?.split(",")[0]?.trim() ||    request.headers.get("X-Real-IP") ||    "unknown";  const country = request.headers.get("CF-IPCountry") || "-";  const ua = request.headers.get("User-Agent")?.slice(0, 80) || "unknown";  return { ip, country, ua };} function normalizeBackendUrl(value: string | null): string | null {  if (!value) return null;  const trimmed = value.trim();  if (!trimmed) return null;  try {    const url = new URL(trimmed);    if (url.protocol !== "http:" && url.protocol !== "https:") return null;    return url.toString().replace(/\/+$/, "");  } catch {    return null;  }} function normalizeOrigin(value: string | null): string | null {  if (!value) return null;  try {    return new URL(value).origin;  } catch {    return null;  }} function getTrustedOverrideOrigins(defaultBackendUrl: string): Set<string> {  const trusted = new Set<string>();   const defaultOrigin = normalizeOrigin(defaultBackendUrl);  if (defaultOrigin) {    trusted.add(defaultOrigin);  }   const rawAllowlist = process.env[OVERRIDE_ALLOWLIST_ENV_KEY] ?? "";  for (const entry of rawAllowlist.split(",")) {    const normalized = normalizeBackendUrl(entry.trim());    const origin = normalizeOrigin(normalized);    if (origin) {      trusted.add(origin);    }  }   return trusted;} function isPrivateUrl(urlString: string): boolean {  try {    const url = new URL(urlString);    const hostname = url.hostname.toLowerCase();    if (      hostname === "localhost" ||      hostname === "127.0.0.1" ||      hostname === "::1" ||      hostname === "0.0.0.0"    )      return true;    if (hostname.endsWith(".local") || hostname.endsWith(".internal")) return true;    // Check private IP ranges    const parts = hostname.split(".");    if (parts.length === 4 && parts.every((p) => /^\d+$/.test(p))) {      const [a, b] = parts.map(Number);      if (a === 10) return true;      if (a === 172 && b !== undefined && b >= 16 && b <= 31) return true;      if (a === 192 && b === 168) return true;      if (a === 169 && b === 254) return true;    }    return false;  } catch {    return true;  }} function isTrustedPrivateOverride(urlString: string, defaultBackendUrl: string): boolean {  // Desktop app (Electron) runs entirely locally — all private IPs are trusted.  if (process.env.VLLM_STUDIO_DATA_DIR) return true;   const targetOrigin = normalizeOrigin(urlString);  if (!targetOrigin) return false;  const trusted = getTrustedOverrideOrigins(defaultBackendUrl);  return trusted.has(targetOrigin);} function buildTargetUrl(backendUrl: string, path: string[], searchParams: string): string {  return `${backendUrl}/${path.join("/")}${searchParams ? `?${searchParams}` : ""}`;} function isAbortError(error: unknown): boolean {  return (    error instanceof Error &&    (error.name === "AbortError" || error.message.toLowerCase().includes("aborted"))  );} function shouldFallbackFromResponse(response: Response): boolean {  if (response.ok) return false;  if (response.status !== 404) return false;  const contentType = response.headers.get("content-type") || "";  return contentType.includes("text/plain");} async function fetchWithOptionalFallback(  primaryUrl: string,  fallbackUrl: string | null,  init: RequestInit,  context: {    client: { ip: string; country: string; ua: string };    method: string;    path: string[];    overrideUsed: boolean;  },): Promise<{ response: Response; usedFallback: boolean }> {  const canFallback = Boolean(context.overrideUsed && fallbackUrl && fallbackUrl !== primaryUrl);   const fetchWithTimeout = async (url: string): Promise<Response> => {    const controller = new AbortController();    const timeoutMs = getUpstreamTimeoutMs(context.path);    const timeoutId = setTimeout(() => controller.abort(), timeoutMs);    try {      return await fetch(url, { ...init, signal: controller.signal });    } finally {      clearTimeout(timeoutId);    }  };   try {    const primaryResponse = await fetchWithTimeout(primaryUrl);    if (canFallback && shouldFallbackFromResponse(primaryResponse)) {      console.warn(        `[PROXY FALLBACK] ip=${context.client.ip} | country=${context.client.country} | method=${context.method} | path=/${context.path.join("/")} | reason=override-404-text`,      );      return { response: await fetchWithTimeout(fallbackUrl as string), usedFallback: true };    }    return { response: primaryResponse, usedFallback: false };  } catch (error) {    if (!canFallback) throw error;    console.warn(      `[PROXY FALLBACK] ip=${context.client.ip} | country=${context.client.country} | method=${context.method} | path=/${context.path.join("/")} | reason=override-network-error | error=${String(error)}`,    );    return { response: await fetchWithTimeout(fallbackUrl as string), usedFallback: true };  }} async function handleRequest(request: NextRequest, method: string, path: string[]) {  const startTime = Date.now();  const client = getClientInfo(request);   try {    // Get dynamic settings    const settings = await getApiSettings();    const overrideHeaderUrl = normalizeBackendUrl(request.headers.get("x-backend-url"));    const overrideCookieUrl = normalizeBackendUrl(      request.cookies.get("vllmstudio_backend_url")?.value ?? null,    );    const defaultBackendUrl = normalizeBackendUrl(settings.backendUrl) ?? settings.backendUrl;     let overrideUrl = overrideHeaderUrl ?? overrideCookieUrl;    const overrideSource = overrideHeaderUrl ? "header" : overrideCookieUrl ? "cookie" : null;    let blockedOverrideCleared = false;     if (overrideUrl && isPrivateUrl(overrideUrl)) {      const trusted = isTrustedPrivateOverride(overrideUrl, defaultBackendUrl);      if (!trusted) {        if (overrideSource === "header") {          console.warn(            `[PROXY BLOCKED] ip=${client.ip} | override=redacted | reason=private-address-not-allowlisted`,          );          return NextResponse.json(            {              error:                "Backend override blocked: private/local addresses must be allowlisted via VLLM_STUDIO_PROXY_OVERRIDE_ALLOWLIST",            },            {              status: 403,              headers: {                "X-Backend-Override-Invalid": "1",                "Set-Cookie": "vllmstudio_backend_url=; Path=/; Max-Age=0; SameSite=Lax",              },            },          );        }         console.warn(          `[PROXY OVERRIDE IGNORED] ip=${client.ip} | override=redacted | reason=private-cookie-not-allowlisted`,        );        overrideUrl = null;        blockedOverrideCleared = true;      }    }     const backendUrl = overrideUrl ?? defaultBackendUrl;    const API_KEY = settings.apiKey;     const url = new URL(request.url);    const forwardedParams = new URLSearchParams(url.searchParams);    const apiKeyQuery = forwardedParams.get("api_key");    // Never forward credentials to the controller as query params.    if (apiKeyQuery) forwardedParams.delete("api_key");    const searchParams = forwardedParams.toString();    const targetUrl = buildTargetUrl(backendUrl, path, searchParams);    const fallbackTargetUrl =      overrideUrl && defaultBackendUrl !== overrideUrl        ? buildTargetUrl(defaultBackendUrl, path, searchParams)        : null;    const hasAuth = Boolean(request.headers.get("authorization"));     if (PROXY_ACCESS_LOGS_ENABLED) {      console.log(        `[PROXY] ip=${client.ip} | country=${client.country} | method=${method} | path=/${path.join("/")} | backend=configured | override=${overrideUrl ? "yes" : "no"} | auth=${hasAuth ? "present" : "none"}`,      );    }     const headers: HeadersInit = {      ...(request.headers.get("accept") ? { Accept: request.headers.get("accept") as string } : {}),    };     const incomingContentType = request.headers.get("content-type");    if (incomingContentType) headers["Content-Type"] = incomingContentType;     // Prefer per-user Authorization header passed from the browser; fallback to configured API key.    const incomingAuth = request.headers.get("authorization");    if (incomingAuth) {      headers["Authorization"] = incomingAuth;    } else if (apiKeyQuery) {      headers["Authorization"] = `Bearer ${apiKeyQuery}`;    } else if (API_KEY) {      headers["Authorization"] = `Bearer ${API_KEY}`;    }     const body = method !== "GET" && method !== "DELETE" ? await request.text() : undefined;     const { response, usedFallback } = await fetchWithOptionalFallback(      targetUrl,      fallbackTargetUrl,      { method, headers, body },      {        client,        method,        path,        overrideUsed: Boolean(overrideUrl),      },    );     const contentType = response.headers.get("content-type") || "application/json";    const invalidateOverride = usedFallback || blockedOverrideCleared;     if (contentType.includes("text/event-stream") && response.body) {      const runId = response.headers.get("x-run-id");      return new NextResponse(response.body, {        status: response.status,        headers: {          "Content-Type": contentType,          "Cache-Control": response.headers.get("cache-control") || "no-cache",          ...(invalidateOverride ? { "X-Backend-Override-Invalid": "1" } : {}),          ...(invalidateOverride            ? { "Set-Cookie": "vllmstudio_backend_url=; Path=/; Max-Age=0; SameSite=Lax" }            : {}),          ...(runId ? { "X-Run-Id": runId } : {}),        },      });    }     const data = await response.text();    return new NextResponse(data, {      status: response.status,      headers: {        "Content-Type": contentType,        ...(invalidateOverride ? { "X-Backend-Override-Invalid": "1" } : {}),        ...(invalidateOverride          ? { "Set-Cookie": "vllmstudio_backend_url=; Path=/; Max-Age=0; SameSite=Lax" }          : {}),      },    });  } catch (error) {    const duration = Date.now() - startTime;    console.error(      `[PROXY ERROR] ip=${client.ip} | country=${client.country} | method=${method} | path=/${path.join("/")} | duration=${duration}ms | error=${String(error)}`,    );    if (isAbortError(error)) {      return NextResponse.json({ error: "Backend request timed out" }, { status: 504 });    }    return NextResponse.json({ error: "Internal server error" }, { status: 500 });  }}