Add a 9Router-side proxy so the Headroom dashboard and its data endpoints (/stats, /health, /stats-history, /transformations/feed) stay same-origin when opened remotely through the 9Router app, and add an "Open Headroom Dashboard" link in the Token Saver modal. Gate /api/headroom/proxy as LOCAL_ONLY (loopback + CLI token) to match start/stop, and strip cookie/authorization when the Headroom target is non-loopback to avoid leaking viewer credentials. Co-authored-by: Cursor <cursoragent@cursor.com>
104 lines
3 KiB
JavaScript
104 lines
3 KiB
JavaScript
import { NextResponse } from "next/server";
|
|
import { getSettings } from "@/lib/localDb";
|
|
import { DEFAULT_HEADROOM_URL } from "@/lib/headroom/detect";
|
|
|
|
export const dynamic = "force-dynamic";
|
|
|
|
const HOP_BY_HOP_HEADERS = new Set([
|
|
"connection",
|
|
"keep-alive",
|
|
"proxy-authenticate",
|
|
"proxy-authorization",
|
|
"te",
|
|
"trailer",
|
|
"transfer-encoding",
|
|
"upgrade",
|
|
]);
|
|
|
|
const DASHBOARD_PREFIX = "/api/headroom/proxy";
|
|
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
|
|
|
|
async function getTargetBase() {
|
|
const settings = await getSettings();
|
|
const url = settings.headroomUrl || DEFAULT_HEADROOM_URL;
|
|
const target = new URL(url);
|
|
if (!["http:", "https:"].includes(target.protocol)) {
|
|
throw new Error("Headroom URL must use http or https");
|
|
}
|
|
return target;
|
|
}
|
|
|
|
function buildTargetUrl(base, path, search) {
|
|
const target = new URL(base);
|
|
target.pathname = `/${path.join("/")}`;
|
|
target.search = search;
|
|
return target;
|
|
}
|
|
|
|
function forwardedHeaders(request, target) {
|
|
const headers = new Headers(request.headers);
|
|
for (const header of headers.keys()) {
|
|
if (HOP_BY_HOP_HEADERS.has(header.toLowerCase())) headers.delete(header);
|
|
}
|
|
headers.delete("host");
|
|
// Never leak viewer credentials to a non-loopback Headroom host
|
|
if (!LOOPBACK_HOSTS.has(target.hostname.replace(/^\[|\]$/g, "").toLowerCase())) {
|
|
headers.delete("cookie");
|
|
headers.delete("authorization");
|
|
}
|
|
return headers;
|
|
}
|
|
|
|
function rewriteDashboardHtml(html) {
|
|
return html.replace(
|
|
/fetch\('(?=\/(?:stats|health|stats-history|transformations\/feed))/g,
|
|
`fetch('${DASHBOARD_PREFIX}`,
|
|
);
|
|
}
|
|
|
|
async function proxy(request, { params }) {
|
|
try {
|
|
const base = await getTargetBase();
|
|
const { search } = new URL(request.url);
|
|
const path = (await params).path || [];
|
|
const target = buildTargetUrl(base, path, search);
|
|
const method = request.method;
|
|
const hasBody = !["GET", "HEAD"].includes(method);
|
|
|
|
const response = await fetch(target, {
|
|
method,
|
|
headers: forwardedHeaders(request, target),
|
|
body: hasBody ? request.body : undefined,
|
|
duplex: hasBody ? "half" : undefined,
|
|
redirect: "manual",
|
|
});
|
|
|
|
const headers = new Headers(response.headers);
|
|
for (const header of headers.keys()) {
|
|
if (HOP_BY_HOP_HEADERS.has(header.toLowerCase())) headers.delete(header);
|
|
}
|
|
|
|
if (path.join("/") === "dashboard") {
|
|
const contentType = response.headers.get("content-type") || "";
|
|
if (contentType.includes("text/html")) {
|
|
headers.delete("content-length");
|
|
return new NextResponse(rewriteDashboardHtml(await response.text()), {
|
|
status: response.status,
|
|
headers,
|
|
});
|
|
}
|
|
}
|
|
|
|
return new NextResponse(response.body, { status: response.status, headers });
|
|
} catch (error) {
|
|
return NextResponse.json({ error: error.message }, { status: 500 });
|
|
}
|
|
}
|
|
|
|
export const GET = proxy;
|
|
export const POST = proxy;
|
|
export const PUT = proxy;
|
|
export const PATCH = proxy;
|
|
export const DELETE = proxy;
|
|
export const HEAD = proxy;
|
|
export const OPTIONS = proxy;
|