Spaces:
Running
Running
Commit ·
35e67fe
1
Parent(s): 6113d21
Implement space privacy detection and redirection
Browse filesAdded functionality to detect space privacy via HF API at startup and cache the result. Updated request handling to redirect users based on space privacy status.
- health-server.js +102 -2
health-server.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
// Single public entrypoint for HF Spaces: dashboard + reverse proxy to OpenClaw + JupyterLab.
|
| 2 |
const http = require("http");
|
|
|
|
| 3 |
const fs = require("fs");
|
| 4 |
const net = require("net");
|
| 5 |
|
|
@@ -52,6 +53,50 @@ function deriveHfSpaceUrl() {
|
|
| 52 |
return "";
|
| 53 |
}
|
| 54 |
const HF_SPACE_URL = deriveHfSpaceUrl();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
const CLOUDFLARE_KEEPALIVE_STATUS_FILE =
|
| 56 |
"/tmp/huggingclaw-cloudflare-keepalive-status.json";
|
| 57 |
|
|
@@ -137,9 +182,24 @@ function renderDashboard(data) {
|
|
| 137 |
tile({ title: "Model", value: `<code>${escapeHtml(LLM_MODEL)}</code>`, detail: "Primary LLM configured", tone: "neutral" }),
|
| 138 |
tile({ title: "Runtime", value: escapeHtml(data.uptimeHuman), detail: `Public port ${PORT}`, tone: "neutral" }),
|
| 139 |
tile({ title: "Telegram", value: badge(TELEGRAM_ENABLED ? "Enabled" : "Disabled", TELEGRAM_ENABLED ? "ok" : "neutral"), detail: TELEGRAM_ENABLED ? "Bot channel active" : "Not configured", tone: TELEGRAM_ENABLED ? "ok" : "neutral" }),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
tile({ title: "Backup", value: badge(syncStatus.toUpperCase(), syncTone), detail: escapeHtml(data.sync?.message || "No status yet"), tone: syncTone, meta: data.sync?.timestamp ? `<span class="local-time" data-iso="${data.sync.timestamp}"></span>` : "" }),
|
| 141 |
tile({ title: "Keep Awake", value: badge(kaConf ? "CF Cron" : kaStatus.toUpperCase(), kaTone), detail: kaConf ? `Pinging <code>${escapeHtml(data.keepalive?.targetUrl || "/health")}</code>` : process.env.CLOUDFLARE_WORKERS_TOKEN ? "Worker pending or failed" : "Not configured", tone: kaTone }),
|
| 142 |
-
|
| 143 |
|
| 144 |
if (JUPYTER_ENABLED) {
|
| 145 |
tiles.push(tile({ title: "Terminal", value: badge(data.jupyterReady ? "Online" : "Starting…", data.jupyterReady ? "ok" : "warn"), detail: `JupyterLab at <a href="${JUPYTER_BASE}/" style="color:inherit">${JUPYTER_BASE}/</a>`, tone: data.jupyterReady ? "ok" : "warn" }));
|
|
@@ -198,11 +258,12 @@ function renderDashboard(data) {
|
|
| 198 |
const inEmbeddedApp = (() => { try { return window.top !== window.self; } catch { return true; } })();
|
| 199 |
const isDirectHfSpaceHost = /\.hf\.space$/i.test(window.location.hostname);
|
| 200 |
const HF_SPACE_URL = ${JSON.stringify(HF_SPACE_URL)};
|
|
|
|
| 201 |
// ── Private Space Guard ──
|
| 202 |
// Direct .hf.space access outside the HF App iframe has no valid session cookie
|
| 203 |
// for private spaces — HF CDN returns 404 before the request reaches the container.
|
| 204 |
// Redirect users to huggingface.co/spaces/... which authenticates them properly.
|
| 205 |
-
if (isDirectHfSpaceHost && !inEmbeddedApp && HF_SPACE_URL) {
|
| 206 |
const notice = document.createElement('div');
|
| 207 |
notice.style.cssText = 'position:fixed;inset:0;display:flex;align-items:center;justify-content:center;background:#08080f;color:#f6f4ff;font-family:sans-serif;flex-direction:column;gap:16px;z-index:9999';
|
| 208 |
notice.innerHTML = '<span style="font-size:1.1rem">🔒 Private Space — Redirecting…</span><a href="' + HF_SPACE_URL + '" style="color:#a5b4fc;font-size:.85rem">Click here if not redirected</a>';
|
|
@@ -379,7 +440,23 @@ const server = http.createServer(async (req, res) => {
|
|
| 379 |
return res.end("SPACE_ID not configured.");
|
| 380 |
}
|
| 381 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 382 |
if (pathname === "/env-builder" || pathname === "/env-builder/") {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 383 |
res.writeHead(200, { "Content-Type": "text/html" });
|
| 384 |
return res.end(renderEnvBuilder());
|
| 385 |
}
|
|
@@ -396,6 +473,10 @@ const server = http.createServer(async (req, res) => {
|
|
| 396 |
}
|
| 397 |
|
| 398 |
if (pathname === "/" || pathname === "/dashboard") {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 399 |
const [gatewayReady, jupyterReady] = await Promise.all([
|
| 400 |
probePort(GATEWAY_HOST, GATEWAY_PORT, "/health"),
|
| 401 |
JUPYTER_ENABLED ? probePort(JUPYTER_HOST, JUPYTER_PORT, `${JUPYTER_BASE}/login`) : Promise.resolve(false),
|
|
@@ -410,6 +491,10 @@ const server = http.createServer(async (req, res) => {
|
|
| 410 |
res.writeHead(404, { "Content-Type": "application/json" });
|
| 411 |
return res.end(JSON.stringify({ status: "disabled", message: "JupyterLab terminal is disabled. Set DEV_MODE=true to enable /terminal/." }));
|
| 412 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 413 |
return proxyHTTP(req, res, JUPYTER_HOST, JUPYTER_PORT, {
|
| 414 |
publicPrefix: JUPYTER_BASE,
|
| 415 |
// Jupyter is started with --ServerApp.base_url=/terminal/, so keep the
|
|
@@ -422,6 +507,10 @@ const server = http.createServer(async (req, res) => {
|
|
| 422 |
// OpenClaw Control UI mounted under /app. Retry without the mount prefix on
|
| 423 |
// 404 so deployments keep working across OpenClaw basePath behavior changes.
|
| 424 |
if (pathname === APP_BASE || pathname.startsWith(APP_BASE + "/")) {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 425 |
return proxyHTTP(req, res, GATEWAY_HOST, GATEWAY_PORT, {
|
| 426 |
publicPrefix: APP_BASE,
|
| 427 |
stripPrefix: APP_BASE,
|
|
@@ -429,7 +518,18 @@ const server = http.createServer(async (req, res) => {
|
|
| 429 |
});
|
| 430 |
}
|
| 431 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 432 |
// OpenClaw gateway API/static fallback (everything else)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 433 |
proxyHTTP(req, res, GATEWAY_HOST, GATEWAY_PORT);
|
| 434 |
});
|
| 435 |
|
|
|
|
| 1 |
// Single public entrypoint for HF Spaces: dashboard + reverse proxy to OpenClaw + JupyterLab.
|
| 2 |
const http = require("http");
|
| 3 |
+
const https = require("https");
|
| 4 |
const fs = require("fs");
|
| 5 |
const net = require("net");
|
| 6 |
|
|
|
|
| 53 |
return "";
|
| 54 |
}
|
| 55 |
const HF_SPACE_URL = deriveHfSpaceUrl();
|
| 56 |
+
|
| 57 |
+
// Auto-detect space privacy via HF API at startup.
|
| 58 |
+
// Caches result so every request doesn't hit the API.
|
| 59 |
+
let SPACE_IS_PRIVATE = false;
|
| 60 |
+
async function detectSpacePrivacy() {
|
| 61 |
+
if (!SPACE_ID) return;
|
| 62 |
+
try {
|
| 63 |
+
const token = (process.env.HF_TOKEN || "").trim();
|
| 64 |
+
const reqOptions = {
|
| 65 |
+
hostname: "huggingface.co",
|
| 66 |
+
path: `/api/spaces/${SPACE_ID}`,
|
| 67 |
+
method: "GET",
|
| 68 |
+
headers: Object.assign(
|
| 69 |
+
{ "User-Agent": "HuggingClaw/health-server" },
|
| 70 |
+
token ? { Authorization: `Bearer ${token}` } : {}
|
| 71 |
+
),
|
| 72 |
+
};
|
| 73 |
+
await new Promise((resolve) => {
|
| 74 |
+
const r = https.request(reqOptions, (res) => {
|
| 75 |
+
let body = "";
|
| 76 |
+
res.on("data", (chunk) => { body += chunk; });
|
| 77 |
+
res.on("end", () => {
|
| 78 |
+
try {
|
| 79 |
+
if (res.statusCode === 200) {
|
| 80 |
+
const data = JSON.parse(body);
|
| 81 |
+
SPACE_IS_PRIVATE = data.private === true;
|
| 82 |
+
} else if (res.statusCode === 404 && !token) {
|
| 83 |
+
// 404 with no token usually means private space
|
| 84 |
+
SPACE_IS_PRIVATE = true;
|
| 85 |
+
}
|
| 86 |
+
} catch {}
|
| 87 |
+
resolve();
|
| 88 |
+
});
|
| 89 |
+
});
|
| 90 |
+
r.on("error", resolve);
|
| 91 |
+
r.setTimeout(5000, () => { r.destroy(); resolve(); });
|
| 92 |
+
r.end();
|
| 93 |
+
});
|
| 94 |
+
console.log(`[health-server] Space privacy detected: ${SPACE_IS_PRIVATE ? "private" : "public"}`);
|
| 95 |
+
} catch {
|
| 96 |
+
// Network error — default to false (safe)
|
| 97 |
+
}
|
| 98 |
+
}
|
| 99 |
+
detectSpacePrivacy();
|
| 100 |
const CLOUDFLARE_KEEPALIVE_STATUS_FILE =
|
| 101 |
"/tmp/huggingclaw-cloudflare-keepalive-status.json";
|
| 102 |
|
|
|
|
| 182 |
tile({ title: "Model", value: `<code>${escapeHtml(LLM_MODEL)}</code>`, detail: "Primary LLM configured", tone: "neutral" }),
|
| 183 |
tile({ title: "Runtime", value: escapeHtml(data.uptimeHuman), detail: `Public port ${PORT}`, tone: "neutral" }),
|
| 184 |
tile({ title: "Telegram", value: badge(TELEGRAM_ENABLED ? "Enabled" : "Disabled", TELEGRAM_ENABLED ? "ok" : "neutral"), detail: TELEGRAM_ENABLED ? "Bot channel active" : "Not configured", tone: TELEGRAM_ENABLED ? "ok" : "neutral" }),
|
| 185 |
+
];
|
| 186 |
+
|
| 187 |
+
if (WHATSAPP_ENABLED) {
|
| 188 |
+
const wa = data.whatsapp || {};
|
| 189 |
+
const waTone = wa.connected ? "ok" : wa.pairing ? "warn" : "neutral";
|
| 190 |
+
const waLabel = wa.connected ? "Connected" : wa.pairing ? "Pairing…" : wa.configured ? "Waiting" : "Disabled";
|
| 191 |
+
const waDetail = wa.connected
|
| 192 |
+
? "WhatsApp channel active"
|
| 193 |
+
: wa.pairing
|
| 194 |
+
? "Scan QR code in Control UI → WhatsApp"
|
| 195 |
+
: "WhatsApp not yet connected";
|
| 196 |
+
tiles.push(tile({ title: "WhatsApp", value: badge(waLabel, waTone), detail: waDetail, tone: waTone }));
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
tiles.push(
|
| 200 |
tile({ title: "Backup", value: badge(syncStatus.toUpperCase(), syncTone), detail: escapeHtml(data.sync?.message || "No status yet"), tone: syncTone, meta: data.sync?.timestamp ? `<span class="local-time" data-iso="${data.sync.timestamp}"></span>` : "" }),
|
| 201 |
tile({ title: "Keep Awake", value: badge(kaConf ? "CF Cron" : kaStatus.toUpperCase(), kaTone), detail: kaConf ? `Pinging <code>${escapeHtml(data.keepalive?.targetUrl || "/health")}</code>` : process.env.CLOUDFLARE_WORKERS_TOKEN ? "Worker pending or failed" : "Not configured", tone: kaTone }),
|
| 202 |
+
);
|
| 203 |
|
| 204 |
if (JUPYTER_ENABLED) {
|
| 205 |
tiles.push(tile({ title: "Terminal", value: badge(data.jupyterReady ? "Online" : "Starting…", data.jupyterReady ? "ok" : "warn"), detail: `JupyterLab at <a href="${JUPYTER_BASE}/" style="color:inherit">${JUPYTER_BASE}/</a>`, tone: data.jupyterReady ? "ok" : "warn" }));
|
|
|
|
| 258 |
const inEmbeddedApp = (() => { try { return window.top !== window.self; } catch { return true; } })();
|
| 259 |
const isDirectHfSpaceHost = /\.hf\.space$/i.test(window.location.hostname);
|
| 260 |
const HF_SPACE_URL = ${JSON.stringify(HF_SPACE_URL)};
|
| 261 |
+
const SPACE_IS_PRIVATE = ${JSON.stringify(SPACE_IS_PRIVATE)};
|
| 262 |
// ── Private Space Guard ──
|
| 263 |
// Direct .hf.space access outside the HF App iframe has no valid session cookie
|
| 264 |
// for private spaces — HF CDN returns 404 before the request reaches the container.
|
| 265 |
// Redirect users to huggingface.co/spaces/... which authenticates them properly.
|
| 266 |
+
if (SPACE_IS_PRIVATE && isDirectHfSpaceHost && !inEmbeddedApp && HF_SPACE_URL) {
|
| 267 |
const notice = document.createElement('div');
|
| 268 |
notice.style.cssText = 'position:fixed;inset:0;display:flex;align-items:center;justify-content:center;background:#08080f;color:#f6f4ff;font-family:sans-serif;flex-direction:column;gap:16px;z-index:9999';
|
| 269 |
notice.innerHTML = '<span style="font-size:1.1rem">🔒 Private Space — Redirecting…</span><a href="' + HF_SPACE_URL + '" style="color:#a5b4fc;font-size:.85rem">Click here if not redirected</a>';
|
|
|
|
| 440 |
return res.end("SPACE_ID not configured.");
|
| 441 |
}
|
| 442 |
|
| 443 |
+
// ── Private Space Guard (server-side) ──
|
| 444 |
+
// Triggers automatically when SPACE_IS_PRIVATE=true (detected via HF API at startup).
|
| 445 |
+
// Only intercepts browser navigation (Accept: text/html) — API calls, assets,
|
| 446 |
+
// and WebSocket upgrades pass through untouched.
|
| 447 |
+
// /health and /status are always exempt so uptime monitors keep working.
|
| 448 |
+
const isHtmlRequest = (req.headers.accept || "").includes("text/html");
|
| 449 |
+
const isDirectHfSpaceRequest = SPACE_IS_PRIVATE &&
|
| 450 |
+
HF_SPACE_URL &&
|
| 451 |
+
isHtmlRequest &&
|
| 452 |
+
typeof req.headers.host === "string" &&
|
| 453 |
+
req.headers.host.endsWith(".hf.space");
|
| 454 |
+
|
| 455 |
if (pathname === "/env-builder" || pathname === "/env-builder/") {
|
| 456 |
+
if (isDirectHfSpaceRequest) {
|
| 457 |
+
res.writeHead(200, { "Content-Type": "text/html" });
|
| 458 |
+
return res.end(renderPrivateRedirect(HF_SPACE_URL));
|
| 459 |
+
}
|
| 460 |
res.writeHead(200, { "Content-Type": "text/html" });
|
| 461 |
return res.end(renderEnvBuilder());
|
| 462 |
}
|
|
|
|
| 473 |
}
|
| 474 |
|
| 475 |
if (pathname === "/" || pathname === "/dashboard") {
|
| 476 |
+
if (isDirectHfSpaceRequest) {
|
| 477 |
+
res.writeHead(200, { "Content-Type": "text/html" });
|
| 478 |
+
return res.end(renderPrivateRedirect(HF_SPACE_URL));
|
| 479 |
+
}
|
| 480 |
const [gatewayReady, jupyterReady] = await Promise.all([
|
| 481 |
probePort(GATEWAY_HOST, GATEWAY_PORT, "/health"),
|
| 482 |
JUPYTER_ENABLED ? probePort(JUPYTER_HOST, JUPYTER_PORT, `${JUPYTER_BASE}/login`) : Promise.resolve(false),
|
|
|
|
| 491 |
res.writeHead(404, { "Content-Type": "application/json" });
|
| 492 |
return res.end(JSON.stringify({ status: "disabled", message: "JupyterLab terminal is disabled. Set DEV_MODE=true to enable /terminal/." }));
|
| 493 |
}
|
| 494 |
+
if (isDirectHfSpaceRequest) {
|
| 495 |
+
res.writeHead(200, { "Content-Type": "text/html" });
|
| 496 |
+
return res.end(renderPrivateRedirect(HF_SPACE_URL));
|
| 497 |
+
}
|
| 498 |
return proxyHTTP(req, res, JUPYTER_HOST, JUPYTER_PORT, {
|
| 499 |
publicPrefix: JUPYTER_BASE,
|
| 500 |
// Jupyter is started with --ServerApp.base_url=/terminal/, so keep the
|
|
|
|
| 507 |
// OpenClaw Control UI mounted under /app. Retry without the mount prefix on
|
| 508 |
// 404 so deployments keep working across OpenClaw basePath behavior changes.
|
| 509 |
if (pathname === APP_BASE || pathname.startsWith(APP_BASE + "/")) {
|
| 510 |
+
if (isDirectHfSpaceRequest) {
|
| 511 |
+
res.writeHead(200, { "Content-Type": "text/html" });
|
| 512 |
+
return res.end(renderPrivateRedirect(HF_SPACE_URL));
|
| 513 |
+
}
|
| 514 |
return proxyHTTP(req, res, GATEWAY_HOST, GATEWAY_PORT, {
|
| 515 |
publicPrefix: APP_BASE,
|
| 516 |
stripPrefix: APP_BASE,
|
|
|
|
| 518 |
});
|
| 519 |
}
|
| 520 |
|
| 521 |
+
// Favicon — serve a minimal inline SVG so browsers don't proxy to the gateway
|
| 522 |
+
if (pathname === "/favicon.ico" || pathname === "/favicon.svg") {
|
| 523 |
+
const svg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text y=".9em" font-size="90">🦞</text></svg>';
|
| 524 |
+
res.writeHead(200, { "Content-Type": "image/svg+xml", "Cache-Control": "public, max-age=86400" });
|
| 525 |
+
return res.end(svg);
|
| 526 |
+
}
|
| 527 |
+
|
| 528 |
// OpenClaw gateway API/static fallback (everything else)
|
| 529 |
+
if (isDirectHfSpaceRequest) {
|
| 530 |
+
res.writeHead(200, { "Content-Type": "text/html" });
|
| 531 |
+
return res.end(renderPrivateRedirect(HF_SPACE_URL));
|
| 532 |
+
}
|
| 533 |
proxyHTTP(req, res, GATEWAY_HOST, GATEWAY_PORT);
|
| 534 |
});
|
| 535 |
|