import { NextRequest, NextResponse } from "next/server"; import { prisma } from "@/lib/prisma"; const MAIN_DOMAIN = process.env.MAIN_DOMAIN || "alfrednobel.edudeploy.com"; async function proxyRequest(req: NextRequest) { const host = req.headers.get("host") || ""; const cleanHost = host.split(":")[0]; if (!cleanHost.endsWith(`.${MAIN_DOMAIN}`)) { return NextResponse.json({ error: "Invalid host" }, { status: 404 }); } const subdomain = cleanHost.replace(`.${MAIN_DOMAIN}`, ""); const instance = await prisma.instance.findUnique({ where: { id: subdomain }, include: { node: true }, }); if (!instance || !instance.node?.tailscaleIp) { return NextResponse.json( { error: "Instance not found or not connected" }, { status: 404 } ); } const publicUrl = `https://${cleanHost}`; const targetUrl = new URL(req.url); // The middleware rewrites /foo to /api/proxy/foo; strip the prefix before forwarding let pathname = targetUrl.pathname; if (pathname.startsWith("/api/proxy")) { pathname = pathname.slice("/api/proxy".length) || "/"; } const upstream = `http://${instance.node.tailscaleIp}:${instance.port}${pathname}${targetUrl.search}`; const headers = new Headers(req.headers); headers.delete("host"); headers.set("host", cleanHost); headers.set("x-forwarded-host", cleanHost); headers.set("x-forwarded-proto", "https"); headers.set("x-forwarded-port", "443"); headers.set("x-forwarded-for", req.headers.get("x-forwarded-for") || "unknown"); const needsBody = req.method !== "GET" && req.method !== "HEAD"; const upstreamRes = await fetch(upstream, { method: req.method, headers, body: needsBody ? (req.body as BodyInit) : undefined, // Node.js fetch requires duplex when forwarding a request body stream ...(needsBody ? { duplex: "half" } : {}), redirect: "manual", }); const responseHeaders = new Headers(upstreamRes.headers); // Remove content-encoding because Next.js/fetch handles decompression automatically responseHeaders.delete("content-encoding"); responseHeaders.delete("content-length"); // Rewrite any header values that point to localhost or the internal Tailscale address const localPatterns = [ `http://${instance.node.tailscaleIp}:${instance.port}`, `https://${instance.node.tailscaleIp}:${instance.port}`, `http://localhost:${instance.port}`, `https://localhost:${instance.port}`, `http://localhost`, `https://localhost`, ]; responseHeaders.forEach((value, key) => { let newValue = value; for (const pattern of localPatterns) { newValue = newValue.replaceAll(pattern, publicUrl); } if (newValue !== value) { responseHeaders.set(key, newValue); } }); // Sanitize Set-Cookie headers so sessions work through the public domain. // WordPress may issue cookies for "localhost" or without Secure flag; fix it. const setCookies = responseHeaders.getSetCookie(); if (setCookies.length > 0) { responseHeaders.delete("set-cookie"); for (let cookie of setCookies) { // Drop any Domain=... set for localhost/internal domains cookie = cookie.replace(/;\s*Domain=[^;]+/gi, ""); // Ensure Secure is present for HTTPS public URLs if (!/;\s*Secure\b/i.test(cookie)) { cookie += "; Secure"; } // Make sure cookies are sent on sub-domain navigations if (!/;\s*SameSite\b/i.test(cookie)) { cookie += "; SameSite=Lax"; } responseHeaders.append("set-cookie", cookie); } } const contentType = responseHeaders.get("content-type") || ""; const shouldRewriteBody = contentType.includes("text/html") || contentType.includes("text/css") || contentType.includes("application/javascript") || contentType.includes("application/json"); if (!shouldRewriteBody) { return new Response(upstreamRes.body, { status: upstreamRes.status, statusText: upstreamRes.statusText, headers: responseHeaders, }); } // For text responses, rewrite localhost/internal URLs to the public URL. // Also handle protocol-relative URLs that some WordPress plugins/themes use. let body = await upstreamRes.text(); const localBase = `http://${instance.node.tailscaleIp}:${instance.port}`; const localBaseHttps = `https://${instance.node.tailscaleIp}:${instance.port}`; const localLocalhostHttp = `http://localhost:${instance.port}`; const localLocalhostHttps = `https://localhost:${instance.port}`; const localLocalhostPlainHttp = `http://localhost`; const localLocalhostPlainHttps = `https://localhost`; const localLocalhostProtocolRelative = `//localhost`; const localTailscaleProtocolRelative = `//${instance.node.tailscaleIp}:${instance.port}`; body = body .replaceAll(localBase, publicUrl) .replaceAll(localBaseHttps, publicUrl) .replaceAll(localLocalhostHttp, publicUrl) .replaceAll(localLocalhostHttps, publicUrl) .replaceAll(localLocalhostPlainHttp, publicUrl) .replaceAll(localLocalhostPlainHttps, publicUrl) .replaceAll(localTailscaleProtocolRelative, publicUrl.replace(/^https?:/, "")) .replaceAll(localLocalhostProtocolRelative, publicUrl.replace(/^https?:/, "")); return new Response(body, { status: upstreamRes.status, statusText: upstreamRes.statusText, headers: responseHeaders, }); } export const GET = proxyRequest; export const POST = proxyRequest; export const PUT = proxyRequest; export const PATCH = proxyRequest; export const DELETE = proxyRequest; export const HEAD = proxyRequest; export const OPTIONS = proxyRequest;