Spaces:
Running
Running
Merge branch 'main' into main
Browse files- health-server.js +97 -38
health-server.js
CHANGED
|
@@ -3,7 +3,7 @@ const http = require("http");
|
|
| 3 |
const https = require("https");
|
| 4 |
const fs = require("fs");
|
| 5 |
const net = require("net");
|
| 6 |
-
const
|
| 7 |
|
| 8 |
function isTrue(value) {
|
| 9 |
return /^(true|1|yes|on)$/i.test(String(value || "").trim());
|
|
@@ -22,6 +22,8 @@ const JUPYTER_PORT = Number.parseInt(process.env.JUPYTER_PORT || "8888", 10);
|
|
| 22 |
const JUPYTER_HOST = "127.0.0.1";
|
| 23 |
const JUPYTER_BASE = normalizeBase(process.env.JUPYTER_BASE, "/terminal");
|
| 24 |
const GATEWAY_TOKEN = (process.env.GATEWAY_TOKEN || "").trim();
|
|
|
|
|
|
|
| 25 |
const DEV_MODE_ENABLED = isTrue(process.env.DEV_MODE);
|
| 26 |
// Explicit HUGGINGCLAW_JUPYTER_ENABLED=true enables Jupyter.
|
| 27 |
// Otherwise DEV_MODE=true enables it unless HUGGINGCLAW_JUPYTER_ENABLED is explicitly false.
|
|
@@ -256,18 +258,58 @@ function parseCookies(req) {
|
|
| 256 |
return Object.fromEntries(h.split(";").map(c => c.trim().split("=")).filter(p => p.length >= 2).map(([k, ...v]) => [k.trim(), decodeURIComponent(v.join("=").trim())]));
|
| 257 |
}
|
| 258 |
|
| 259 |
-
// Constant-time comparison — prevent timing attacks
|
| 260 |
-
function
|
| 261 |
-
if (
|
| 262 |
-
const
|
| 263 |
-
const
|
| 264 |
-
if (
|
| 265 |
-
return timingSafeEqual(
|
| 266 |
}
|
| 267 |
|
| 268 |
-
function
|
| 269 |
-
if (!GATEWAY_TOKEN) return
|
| 270 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 271 |
}
|
| 272 |
|
| 273 |
function readBody(req) {
|
|
@@ -279,10 +321,11 @@ function readBody(req) {
|
|
| 279 |
});
|
| 280 |
}
|
| 281 |
|
| 282 |
-
function
|
|
|
|
| 283 |
return `<!doctype html><html lang="en"><head>
|
| 284 |
<meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/>
|
| 285 |
-
<title>HuggingClaw
|
| 286 |
<style>
|
| 287 |
:root{color-scheme:dark;--bg:#08080f;--panel:#12111b;--line:#26243a;--text:#f6f4ff;--muted:#7f7a9e;--bad:#fb7185}
|
| 288 |
*{box-sizing:border-box}body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--text);padding:24px}
|
|
@@ -290,19 +333,20 @@ function renderEnvBuilderLogin(error = false) {
|
|
| 290 |
h1{margin:0 0 8px;font-size:1.4rem}
|
| 291 |
.sub{color:var(--muted);font-size:.82rem;margin:0 0 24px}
|
| 292 |
.row{display:flex;gap:8px;margin-top:16px}
|
| 293 |
-
input{flex:1;background:#0d0c18;border:1px solid var(--line);border-radius:7px;padding:10px 12px;color:var(--text);font-size:.95rem;outline:none}
|
| 294 |
input:focus{border-color:#6366f1}
|
| 295 |
-
button{background:#fff;color:#000;border:none;border-radius:7px;padding:10px 20px;font-weight:700;font-size:.95rem;cursor:pointer;transition:opacity .15s}
|
| 296 |
button:hover{opacity:.85}
|
| 297 |
.err{color:var(--bad);font-size:.82rem;margin-top:10px}
|
| 298 |
code{background:#232234;border:1px solid #34324c;border-radius:5px;padding:2px 6px;font-size:.88em}
|
| 299 |
</style></head><body>
|
| 300 |
<div class="card">
|
| 301 |
-
<h1>
|
| 302 |
<p class="sub">Enter your <code>GATEWAY_TOKEN</code> to continue</p>
|
| 303 |
-
<form method="post" action="
|
|
|
|
| 304 |
<div class="row">
|
| 305 |
-
<input type="password" name="token" placeholder="GATEWAY_TOKEN" autofocus autocomplete="current-password">
|
| 306 |
<button type="submit">Unlock</button>
|
| 307 |
</div>
|
| 308 |
${error ? '<p class="err">Invalid token — try again</p>' : ""}
|
|
@@ -567,6 +611,7 @@ function proxyHTTP(req, res, targetHost, targetPort, options = {}) {
|
|
| 567 |
"x-forwarded-host": req.headers.host,
|
| 568 |
"x-forwarded-proto": "https",
|
| 569 |
"x-forwarded-prefix": options.publicPrefix || "",
|
|
|
|
| 570 |
};
|
| 571 |
|
| 572 |
const canReplayRequest = req.method === "GET" || req.method === "HEAD";
|
|
@@ -678,24 +723,37 @@ const server = http.createServer(async (req, res) => {
|
|
| 678 |
!isSameOriginNav &&
|
| 679 |
!isFromHFApp;
|
| 680 |
|
| 681 |
-
if (pathname ===
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 682 |
if (req.method === "POST") {
|
| 683 |
const body = await readBody(req);
|
| 684 |
-
const
|
| 685 |
-
|
| 686 |
-
|
| 687 |
-
|
|
|
|
| 688 |
return res.end();
|
| 689 |
}
|
| 690 |
-
res.writeHead(
|
| 691 |
-
return res.end(
|
| 692 |
}
|
| 693 |
-
res.writeHead(
|
| 694 |
-
return res.end();
|
| 695 |
}
|
| 696 |
|
| 697 |
-
if (pathname === "/
|
| 698 |
-
res.writeHead(302, { Location:
|
| 699 |
return res.end();
|
| 700 |
}
|
| 701 |
|
|
@@ -704,19 +762,13 @@ const server = http.createServer(async (req, res) => {
|
|
| 704 |
res.writeHead(200, { "Content-Type": "text/html", "Cache-Control": "no-store" });
|
| 705 |
return res.end(renderPrivateRedirect(HF_SPACE_URL));
|
| 706 |
}
|
| 707 |
-
if (!
|
| 708 |
-
|
| 709 |
-
return res.end(renderEnvBuilderLogin(false));
|
| 710 |
-
}
|
| 711 |
-
res.writeHead(200, { "Content-Type": "text/html", "Cache-Control": "no-store" });
|
| 712 |
return res.end(renderEnvBuilder());
|
| 713 |
}
|
| 714 |
|
| 715 |
if (pathname === "/env-builder.js") {
|
| 716 |
-
if (!
|
| 717 |
-
res.writeHead(401, { "Content-Type": "text/plain" });
|
| 718 |
-
return res.end("Unauthorized");
|
| 719 |
-
}
|
| 720 |
try {
|
| 721 |
const js = fs.readFileSync(require("path").join(__dirname, "env-builder.js"), "utf8");
|
| 722 |
res.writeHead(200, { "Content-Type": "application/javascript", "Cache-Control": "no-store" });
|
|
@@ -751,12 +803,19 @@ const server = http.createServer(async (req, res) => {
|
|
| 751 |
res.writeHead(200, { "Content-Type": "text/html" });
|
| 752 |
return res.end(renderPrivateRedirect(HF_SPACE_URL));
|
| 753 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 754 |
return proxyHTTP(req, res, JUPYTER_HOST, JUPYTER_PORT, {
|
| 755 |
publicPrefix: JUPYTER_BASE,
|
| 756 |
// Jupyter is started with --ServerApp.base_url=/terminal/, so keep the
|
| 757 |
// /terminal prefix when proxying. Stripping it breaks static/theme URLs.
|
| 758 |
stripPrefix: "",
|
| 759 |
retryWithoutPrefixOn404: false,
|
|
|
|
| 760 |
});
|
| 761 |
}
|
| 762 |
|
|
|
|
| 3 |
const https = require("https");
|
| 4 |
const fs = require("fs");
|
| 5 |
const net = require("net");
|
| 6 |
+
const crypto = require("crypto");
|
| 7 |
|
| 8 |
function isTrue(value) {
|
| 9 |
return /^(true|1|yes|on)$/i.test(String(value || "").trim());
|
|
|
|
| 22 |
const JUPYTER_HOST = "127.0.0.1";
|
| 23 |
const JUPYTER_BASE = normalizeBase(process.env.JUPYTER_BASE, "/terminal");
|
| 24 |
const GATEWAY_TOKEN = (process.env.GATEWAY_TOKEN || "").trim();
|
| 25 |
+
const SESSION_COOKIE = "hc_session";
|
| 26 |
+
const LOGIN_PATH = "/login";
|
| 27 |
const DEV_MODE_ENABLED = isTrue(process.env.DEV_MODE);
|
| 28 |
// Explicit HUGGINGCLAW_JUPYTER_ENABLED=true enables Jupyter.
|
| 29 |
// Otherwise DEV_MODE=true enables it unless HUGGINGCLAW_JUPYTER_ENABLED is explicitly false.
|
|
|
|
| 258 |
return Object.fromEntries(h.split(";").map(c => c.trim().split("=")).filter(p => p.length >= 2).map(([k, ...v]) => [k.trim(), decodeURIComponent(v.join("=").trim())]));
|
| 259 |
}
|
| 260 |
|
| 261 |
+
// Constant-time comparison using crypto — prevent timing attacks
|
| 262 |
+
function timingSafeEqualString(a, b) {
|
| 263 |
+
if (!a || !b) return false;
|
| 264 |
+
const bufA = Buffer.from(a);
|
| 265 |
+
const bufB = Buffer.from(b);
|
| 266 |
+
if (bufA.length !== bufB.length) return false;
|
| 267 |
+
return crypto.timingSafeEqual(bufA, bufB);
|
| 268 |
}
|
| 269 |
|
| 270 |
+
function expectedSessionValue() {
|
| 271 |
+
if (!GATEWAY_TOKEN) return "";
|
| 272 |
+
return crypto.createHmac("sha256", GATEWAY_TOKEN).update("huggingclaw-session-v1").digest("hex");
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
function isHttpsRequest(req) {
|
| 276 |
+
return req.headers["x-forwarded-proto"] === "https";
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
function buildSessionCookie(req) {
|
| 280 |
+
const secure = isHttpsRequest(req) ? "; Secure" : "";
|
| 281 |
+
return `${SESSION_COOKIE}=${encodeURIComponent(expectedSessionValue())}; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400${secure}`;
|
| 282 |
+
}
|
| 283 |
+
|
| 284 |
+
function getBearerToken(req) {
|
| 285 |
+
const match = /^Bearer\s+(.+)$/i.exec(req.headers.authorization || "");
|
| 286 |
+
return match ? match[1] : "";
|
| 287 |
+
}
|
| 288 |
+
|
| 289 |
+
function isAuthorized(req) {
|
| 290 |
+
if (!GATEWAY_TOKEN) return true;
|
| 291 |
+
return (
|
| 292 |
+
timingSafeEqualString(getBearerToken(req), GATEWAY_TOKEN) ||
|
| 293 |
+
timingSafeEqualString(parseCookies(req)[SESSION_COOKIE], expectedSessionValue())
|
| 294 |
+
);
|
| 295 |
+
}
|
| 296 |
+
|
| 297 |
+
function sanitizeNext(value) {
|
| 298 |
+
if (!value || typeof value !== "string") return "/";
|
| 299 |
+
if (!value.startsWith("/") || value.startsWith("//")) return "/";
|
| 300 |
+
return value;
|
| 301 |
+
}
|
| 302 |
+
|
| 303 |
+
function loginUrl(nextPath) {
|
| 304 |
+
return `${LOGIN_PATH}?next=${encodeURIComponent(sanitizeNext(nextPath))}`;
|
| 305 |
+
}
|
| 306 |
+
|
| 307 |
+
function requireAuth(req, res) {
|
| 308 |
+
if (isAuthorized(req)) return true;
|
| 309 |
+
const parsed = parseRequestUrl(req.url);
|
| 310 |
+
res.writeHead(302, { Location: loginUrl(parsed.pathname + parsed.search), "Cache-Control": "no-store" });
|
| 311 |
+
res.end();
|
| 312 |
+
return false;
|
| 313 |
}
|
| 314 |
|
| 315 |
function readBody(req) {
|
|
|
|
| 321 |
});
|
| 322 |
}
|
| 323 |
|
| 324 |
+
function renderLoginPage(nextPath = "/", error = false) {
|
| 325 |
+
const safeNext = sanitizeNext(nextPath);
|
| 326 |
return `<!doctype html><html lang="en"><head>
|
| 327 |
<meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/>
|
| 328 |
+
<title>HuggingClaw</title>
|
| 329 |
<style>
|
| 330 |
:root{color-scheme:dark;--bg:#08080f;--panel:#12111b;--line:#26243a;--text:#f6f4ff;--muted:#7f7a9e;--bad:#fb7185}
|
| 331 |
*{box-sizing:border-box}body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--text);padding:24px}
|
|
|
|
| 333 |
h1{margin:0 0 8px;font-size:1.4rem}
|
| 334 |
.sub{color:var(--muted);font-size:.82rem;margin:0 0 24px}
|
| 335 |
.row{display:flex;gap:8px;margin-top:16px}
|
| 336 |
+
input{flex:1;background:#0d0c18;border:1px solid var(--line);border-radius:7px;padding:10px 12px;color:var(--text);font-size:.95rem;outline:none;transition:border-color .15s}
|
| 337 |
input:focus{border-color:#6366f1}
|
| 338 |
+
button{background:#fff;color:#000;border:none;border-radius:7px;padding:10px 20px;font-weight:700;font-size:.95rem;cursor:pointer;transition:opacity .15s;white-space:nowrap}
|
| 339 |
button:hover{opacity:.85}
|
| 340 |
.err{color:var(--bad);font-size:.82rem;margin-top:10px}
|
| 341 |
code{background:#232234;border:1px solid #34324c;border-radius:5px;padding:2px 6px;font-size:.88em}
|
| 342 |
</style></head><body>
|
| 343 |
<div class="card">
|
| 344 |
+
<h1>🦞 HuggingClaw</h1>
|
| 345 |
<p class="sub">Enter your <code>GATEWAY_TOKEN</code> to continue</p>
|
| 346 |
+
<form method="post" action="${LOGIN_PATH}">
|
| 347 |
+
<input type="hidden" name="next" value="${escapeHtml(safeNext)}" />
|
| 348 |
<div class="row">
|
| 349 |
+
<input type="password" name="token" placeholder="GATEWAY_TOKEN" autofocus autocomplete="current-password" required>
|
| 350 |
<button type="submit">Unlock</button>
|
| 351 |
</div>
|
| 352 |
${error ? '<p class="err">Invalid token — try again</p>' : ""}
|
|
|
|
| 611 |
"x-forwarded-host": req.headers.host,
|
| 612 |
"x-forwarded-proto": "https",
|
| 613 |
"x-forwarded-prefix": options.publicPrefix || "",
|
| 614 |
+
...(options.extraHeaders || {}),
|
| 615 |
};
|
| 616 |
|
| 617 |
const canReplayRequest = req.method === "GET" || req.method === "HEAD";
|
|
|
|
| 723 |
!isSameOriginNav &&
|
| 724 |
!isFromHFApp;
|
| 725 |
|
| 726 |
+
if (pathname === LOGIN_PATH) {
|
| 727 |
+
if (isAuthorized(req)) {
|
| 728 |
+
const parsed = parseRequestUrl(req.url);
|
| 729 |
+
const next = sanitizeNext(parsed.searchParams.get("next") || "/");
|
| 730 |
+
res.writeHead(302, { Location: next, "Cache-Control": "no-store" });
|
| 731 |
+
return res.end();
|
| 732 |
+
}
|
| 733 |
+
if (req.method === "GET") {
|
| 734 |
+
const parsed = parseRequestUrl(req.url);
|
| 735 |
+
const next = sanitizeNext(parsed.searchParams.get("next") || "/");
|
| 736 |
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" });
|
| 737 |
+
return res.end(renderLoginPage(next, false));
|
| 738 |
+
}
|
| 739 |
if (req.method === "POST") {
|
| 740 |
const body = await readBody(req);
|
| 741 |
+
const params = new URLSearchParams(body);
|
| 742 |
+
const submittedToken = params.get("token") || "";
|
| 743 |
+
const next = sanitizeNext(params.get("next") || "/");
|
| 744 |
+
if (!GATEWAY_TOKEN || timingSafeEqualString(submittedToken, GATEWAY_TOKEN)) {
|
| 745 |
+
res.writeHead(302, { Location: next, "Set-Cookie": buildSessionCookie(req), "Cache-Control": "no-store" });
|
| 746 |
return res.end();
|
| 747 |
}
|
| 748 |
+
res.writeHead(401, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" });
|
| 749 |
+
return res.end(renderLoginPage(next, true));
|
| 750 |
}
|
| 751 |
+
res.writeHead(405, { Allow: "GET, POST" });
|
| 752 |
+
return res.end("Method Not Allowed");
|
| 753 |
}
|
| 754 |
|
| 755 |
+
if (pathname === "/logout") {
|
| 756 |
+
res.writeHead(302, { Location: LOGIN_PATH, "Set-Cookie": `${SESSION_COOKIE}=; Path=/; HttpOnly; Max-Age=0`, "Cache-Control": "no-store" });
|
| 757 |
return res.end();
|
| 758 |
}
|
| 759 |
|
|
|
|
| 762 |
res.writeHead(200, { "Content-Type": "text/html", "Cache-Control": "no-store" });
|
| 763 |
return res.end(renderPrivateRedirect(HF_SPACE_URL));
|
| 764 |
}
|
| 765 |
+
if (!requireAuth(req, res)) return;
|
| 766 |
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
|
|
|
|
|
|
|
|
| 767 |
return res.end(renderEnvBuilder());
|
| 768 |
}
|
| 769 |
|
| 770 |
if (pathname === "/env-builder.js") {
|
| 771 |
+
if (!requireAuth(req, res)) return;
|
|
|
|
|
|
|
|
|
|
| 772 |
try {
|
| 773 |
const js = fs.readFileSync(require("path").join(__dirname, "env-builder.js"), "utf8");
|
| 774 |
res.writeHead(200, { "Content-Type": "application/javascript", "Cache-Control": "no-store" });
|
|
|
|
| 803 |
res.writeHead(200, { "Content-Type": "text/html" });
|
| 804 |
return res.end(renderPrivateRedirect(HF_SPACE_URL));
|
| 805 |
}
|
| 806 |
+
if (!requireAuth(req, res)) return;
|
| 807 |
+
// Inject the Jupyter token so JupyterLab skips its own login screen.
|
| 808 |
+
// Mirror start.sh logic: JUPYTER_TOKEN falls back to GATEWAY_TOKEN when
|
| 809 |
+
// unset or still the insecure default — that's what Jupyter was started with.
|
| 810 |
+
const rawJupyterToken = (process.env.JUPYTER_TOKEN || "").trim();
|
| 811 |
+
const jToken = (!rawJupyterToken || rawJupyterToken === "huggingface") ? GATEWAY_TOKEN : rawJupyterToken;
|
| 812 |
return proxyHTTP(req, res, JUPYTER_HOST, JUPYTER_PORT, {
|
| 813 |
publicPrefix: JUPYTER_BASE,
|
| 814 |
// Jupyter is started with --ServerApp.base_url=/terminal/, so keep the
|
| 815 |
// /terminal prefix when proxying. Stripping it breaks static/theme URLs.
|
| 816 |
stripPrefix: "",
|
| 817 |
retryWithoutPrefixOn404: false,
|
| 818 |
+
extraHeaders: jToken ? { authorization: `token ${jToken}` } : {},
|
| 819 |
});
|
| 820 |
}
|
| 821 |
|