fix(headroom): proxy dashboard through app (#2372)

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>
This commit is contained in:
Sutarto Jordan Chrisfivo
2026-07-05 17:40:41 +07:00
committed by decolua
parent 008de32c06
commit 481e7e467b
3 changed files with 115 additions and 0 deletions

View File

@@ -374,6 +374,16 @@ export default function TokenSaverClient() {
{headroomStatusLabel}
</span>
</div>
{headroomRunning && (
<a
href="/api/headroom/proxy/dashboard"
target="_blank"
rel="noreferrer"
className="w-full rounded border border-border px-4 py-2 text-center text-sm hover:bg-surface-2"
>
Open Headroom Dashboard
</a>
)}
<div className="flex flex-col gap-1">
<p className="text-sm font-medium">Proxy URL</p>
<Input

View File

@@ -0,0 +1,104 @@
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;

View File

@@ -81,6 +81,7 @@ const LOCAL_ONLY_PATHS = [
"/api/auth/reset-password",
"/api/headroom/start",
"/api/headroom/stop",
"/api/headroom/proxy",
];
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);