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:
committed by
decolua
parent
008de32c06
commit
481e7e467b
@@ -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
|
||||
|
||||
104
src/app/api/headroom/proxy/[...path]/route.js
Normal file
104
src/app/api/headroom/proxy/[...path]/route.js
Normal 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;
|
||||
@@ -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"]);
|
||||
|
||||
Reference in New Issue
Block a user