| "use strict"; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| const http = require("http"); |
| const fs = require("fs"); |
| const net = require("net"); |
| const crypto = require("crypto"); |
|
|
| const PORT = Number(process.env.PORT || 7861); |
| const GATEWAY_PORT = Number(process.env.API_SERVER_PORT || 8642); |
| const DASHBOARD_PORT = Number(process.env.DASHBOARD_PORT || 9119); |
| const TELEGRAM_WEBHOOK_PORT = Number(process.env.TELEGRAM_WEBHOOK_PORT || 8765); |
| const WEBUI_PORT = Number(process.env.HERMES_WEBUI_PORT || 8787); |
| const GATEWAY_HOST = "127.0.0.1"; |
| const startTime = Date.now(); |
| const API_SERVER_KEY = process.env.API_SERVER_KEY || ""; |
| const HM_PREFIX = "/hm"; |
| const LOGIN_PATH = "/hm/login"; |
| const SESSION_COOKIE = "huggingmes_session"; |
| const PRIMARY_UI = (process.env.PRIMARY_UI || "webui").toLowerCase(); |
|
|
| const SYNC_STATUS_FILE = "/tmp/huggingmes-sync-status.json"; |
| const CLOUDFLARE_KEEPALIVE_STATUS_FILE = |
| "/tmp/huggingmes-cloudflare-keepalive-status.json"; |
|
|
| |
|
|
| function canConnect(port, host = GATEWAY_HOST, timeoutMs = 600) { |
| return new Promise((resolve) => { |
| const socket = net.createConnection({ port, host }); |
| const done = (ok) => { |
| socket.removeAllListeners(); |
| socket.destroy(); |
| resolve(ok); |
| }; |
| socket.setTimeout(timeoutMs); |
| socket.once("connect", () => done(true)); |
| socket.once("timeout", () => done(false)); |
| socket.once("error", () => done(false)); |
| }); |
| } |
|
|
| function readJson(path, fallback = null) { |
| try { |
| if (fs.existsSync(path)) return JSON.parse(fs.readFileSync(path, "utf8")); |
| } catch {} |
| return fallback; |
| } |
|
|
| function timingSafeEqualString(left, right) { |
| if (!left || !right) return false; |
| const a = Buffer.from(left); |
| const b = Buffer.from(right); |
| if (a.length !== b.length) return false; |
| return crypto.timingSafeEqual(a, b); |
| } |
|
|
| function expectedSessionValue() { |
| if (!API_SERVER_KEY) return ""; |
| return crypto |
| .createHmac("sha256", API_SERVER_KEY) |
| .update("huggingmes-session-v1") |
| .digest("hex"); |
| } |
|
|
| function parseCookies(req) { |
| const header = req.headers.cookie || ""; |
| const cookies = {}; |
| for (const item of header.split(";")) { |
| const sep = item.indexOf("="); |
| if (sep < 0) continue; |
| const name = item.slice(0, sep).trim(); |
| const value = item.slice(sep + 1).trim(); |
| if (!name) continue; |
| try { |
| cookies[name] = decodeURIComponent(value); |
| } catch { |
| cookies[name] = value; |
| } |
| } |
| return cookies; |
| } |
|
|
| function isHttpsRequest(req) { |
| return req.headers["x-forwarded-proto"] === "https"; |
| } |
|
|
| function buildSessionCookie(req) { |
| const secure = isHttpsRequest(req) ? "; Secure" : ""; |
| return `${SESSION_COOKIE}=${encodeURIComponent(expectedSessionValue())}; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400${secure}`; |
| } |
|
|
| function getBearerToken(req) { |
| const value = req.headers.authorization || ""; |
| const match = /^Bearer\s+(.+)$/i.exec(value); |
| return match ? match[1] : ""; |
| } |
|
|
| function isAuthorized(req) { |
| if (!API_SERVER_KEY) return true; |
| return ( |
| timingSafeEqualString(getBearerToken(req), API_SERVER_KEY) || |
| timingSafeEqualString( |
| parseCookies(req)[SESSION_COOKIE], |
| expectedSessionValue(), |
| ) |
| ); |
| } |
|
|
| function sanitizeNext(value, fallback = "/") { |
| if (!value || typeof value !== "string") return fallback; |
| if (!value.startsWith("/") || value.startsWith("//")) return fallback; |
| return value; |
| } |
|
|
| function loginUrl(nextPath) { |
| return `${LOGIN_PATH}?next=${encodeURIComponent(sanitizeNext(nextPath))}`; |
| } |
|
|
| function wantsHtml(req) { |
| const accept = String(req.headers.accept || ""); |
| return accept.includes("text/html"); |
| } |
|
|
| function escapeHtml(value) { |
| return String(value) |
| .replace(/&/g, "&") |
| .replace(/</g, "<") |
| .replace(/>/g, ">") |
| .replace(/"/g, """); |
| } |
|
|
| function readRequestBody(req, limit = 64 * 1024) { |
| return new Promise((resolve, reject) => { |
| let body = ""; |
| req.on("data", (chunk) => { |
| body += chunk; |
| if (body.length > limit) { |
| reject(new Error("Request body is too large.")); |
| req.destroy(); |
| } |
| }); |
| req.on("end", () => resolve(body)); |
| req.on("error", reject); |
| }); |
| } |
|
|
| |
|
|
| function renderLoginPage(nextPath, errorMessage = "") { |
| const safeNext = sanitizeNext(nextPath, "/"); |
| const errorHtml = errorMessage |
| ? `<div class="error">${escapeHtml(errorMessage)}</div>` |
| : ""; |
| return `<!doctype html> |
| <html lang="en"> |
| <head> |
| <meta charset="utf-8" /> |
| <meta name="viewport" content="width=device-width, initial-scale=1" /> |
| <title>HuggingMes + Hermes WebUI β Login</title> |
| <style> |
| :root { color-scheme: dark; --bg:#10141f; --panel:#171d2b; --line:#293246; --text:#f4f7fb; --muted:#9aa7bd; --bad:#ef4444; --accent:#38bdf8; } |
| * { box-sizing:border-box; } |
| body { margin:0; min-height:100vh; display:grid; place-items:center; font-family:Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background:var(--bg); color:var(--text); padding:20px; } |
| main { width:min(440px, 100%); border:1px solid var(--line); background:var(--panel); border-radius:8px; padding:28px; } |
| h1 { margin:0 0 8px; font-size:1.55rem; } |
| p { margin:0 0 22px; color:var(--muted); line-height:1.5; } |
| label { display:block; color:var(--muted); font-size:.82rem; margin-bottom:8px; } |
| input { width:100%; min-height:46px; border:1px solid var(--line); border-radius:7px; background:#0b0f18; color:var(--text); padding:0 12px; font:inherit; } |
| button { width:100%; min-height:44px; margin-top:16px; border:0; border-radius:7px; color:#07111f; background:var(--accent); font:inherit; font-weight:750; cursor:pointer; } |
| .error { border:1px solid rgba(239,68,68,.4); background:rgba(239,68,68,.1); color:#fecaca; border-radius:7px; padding:10px 12px; margin-bottom:16px; } |
| </style> |
| </head> |
| <body> |
| <main> |
| <h1>HuggingMes Admin</h1> |
| <p>Enter the <code>GATEWAY_TOKEN</code> from your Space secrets to access the status dashboard.<br>For the Hermes chat UI, go to <a href="/" style="color:var(--accent)">/</a>.</p> |
| ${errorHtml} |
| <form method="post" action="${LOGIN_PATH}"> |
| <input type="hidden" name="next" value="${escapeHtml(safeNext)}" /> |
| <label for="token">GATEWAY_TOKEN</label> |
| <input id="token" name="token" type="password" autocomplete="current-password" autofocus required /> |
| <button type="submit">Continue</button> |
| </form> |
| </main> |
| </body> |
| </html>`; |
| } |
|
|
| async function handleLogin(req, res, parsed) { |
| const nextPath = sanitizeNext(parsed.searchParams.get("next") || "/", "/"); |
|
|
| if (!API_SERVER_KEY) { |
| redirect(res, nextPath); |
| return; |
| } |
|
|
| if (req.method === "GET") { |
| res.writeHead(200, { |
| "content-type": "text/html; charset=utf-8", |
| "cache-control": "no-store", |
| }); |
| res.end(renderLoginPage(nextPath)); |
| return; |
| } |
|
|
| if (req.method !== "POST") { |
| res.writeHead(405, { allow: "GET, POST" }); |
| res.end("Method not allowed"); |
| return; |
| } |
|
|
| try { |
| const body = await readRequestBody(req); |
| const params = new URLSearchParams(body); |
| const submittedToken = params.get("token") || ""; |
| const submittedNext = sanitizeNext(params.get("next") || nextPath, "/"); |
|
|
| if (!timingSafeEqualString(submittedToken, API_SERVER_KEY)) { |
| res.writeHead(401, { |
| "content-type": "text/html; charset=utf-8", |
| "cache-control": "no-store", |
| }); |
| res.end( |
| renderLoginPage( |
| submittedNext, |
| "That token did not match GATEWAY_TOKEN.", |
| ), |
| ); |
| return; |
| } |
|
|
| res.writeHead(302, { |
| location: submittedNext, |
| "set-cookie": buildSessionCookie(req), |
| "cache-control": "no-store", |
| }); |
| res.end(); |
| } catch (error) { |
| res.writeHead(400, { |
| "content-type": "text/plain; charset=utf-8", |
| "cache-control": "no-store", |
| }); |
| res.end(error.message || "Invalid login request."); |
| } |
| } |
|
|
| function requireAuth(req, res) { |
| if (isAuthorized(req)) return true; |
| const parsed = new URL(req.url, "http://localhost"); |
| redirect(res, loginUrl(`${parsed.pathname}${parsed.search}`)); |
| return false; |
| } |
|
|
| |
|
|
| function proxyRequest( |
| req, |
| res, |
| targetPort, |
| rewritePath = (path) => path, |
| headerOverrides = {}, |
| ) { |
| const parsed = new URL(req.url, "http://localhost"); |
| const targetPath = rewritePath(parsed.pathname) + parsed.search; |
| const headers = { |
| ...req.headers, |
| ...headerOverrides, |
| host: `${GATEWAY_HOST}:${targetPort}`, |
| "x-forwarded-host": req.headers.host || "", |
| "x-forwarded-proto": req.headers["x-forwarded-proto"] || "https", |
| }; |
|
|
| const proxy = http.request( |
| { |
| hostname: GATEWAY_HOST, |
| port: targetPort, |
| method: req.method, |
| path: targetPath, |
| headers, |
| }, |
| (upstream) => { |
| res.writeHead(upstream.statusCode || 502, upstream.headers); |
| upstream.pipe(res); |
| }, |
| ); |
|
|
| proxy.on("error", (error) => { |
| res.writeHead(502, { "content-type": "application/json" }); |
| res.end(JSON.stringify({ error: "proxy_error", message: error.message })); |
| }); |
|
|
| req.pipe(proxy); |
| } |
|
|
| function redirect(res, location, statusCode = 302) { |
| res.writeHead(statusCode, { location }); |
| res.end(); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| function proxyDashboard(req, res) { |
| const parsed = new URL(req.url, "http://localhost"); |
| const inner = parsed.pathname.replace(`${HM_PREFIX}/app`, "") || "/"; |
|
|
| const isAssetLike = |
| inner.startsWith("/assets/") || |
| inner.startsWith("/api/") || |
| inner.startsWith("/dashboard-plugins/") || |
| inner.startsWith("/ds-assets/") || |
| /\.[a-z0-9]{1,6}$/i.test(inner); |
|
|
| |
| const targetPath = |
| (isAssetLike || inner === "/" ? inner : "/") + parsed.search; |
|
|
| const headers = { |
| ...req.headers, |
| host: `${GATEWAY_HOST}:${DASHBOARD_PORT}`, |
| "x-forwarded-host": req.headers.host || "", |
| "x-forwarded-proto": req.headers["x-forwarded-proto"] || "https", |
| |
| "accept-encoding": "identity", |
| }; |
|
|
| const upstream = http.request( |
| { |
| hostname: GATEWAY_HOST, |
| port: DASHBOARD_PORT, |
| method: req.method, |
| path: targetPath, |
| headers, |
| }, |
| (upRes) => { |
| const contentType = String(upRes.headers["content-type"] || ""); |
| const shouldRewrite = |
| contentType.includes("text/html") || |
| contentType.includes("application/xhtml"); |
|
|
| if (!shouldRewrite) { |
| res.writeHead(upRes.statusCode || 502, upRes.headers); |
| upRes.pipe(res); |
| return; |
| } |
|
|
| const chunks = []; |
| upRes.on("data", (chunk) => chunks.push(chunk)); |
| upRes.on("end", () => { |
| let body = Buffer.concat(chunks).toString("utf8"); |
|
|
| |
| body = body.replace( |
| /window\.__HERMES_BASE_PATH__\s*=\s*"[^"]*"/g, |
| `window.__HERMES_BASE_PATH__="${HM_PREFIX}/app"`, |
| ); |
|
|
| |
| const prefix = `${HM_PREFIX}/app`; |
| body = body.replace( |
| /\b(src|href)="\/(?!\/|http)([^"]*)"/g, |
| (match, attr, rest) => { |
| if ( |
| ("/" + rest).startsWith(prefix + "/") || |
| "/" + rest === prefix |
| ) { |
| return match; |
| } |
| return `${attr}="${prefix}/${rest}"`; |
| }, |
| ); |
|
|
| const buf = Buffer.from(body, "utf8"); |
| const outHeaders = { ...upRes.headers }; |
| delete outHeaders["content-length"]; |
| delete outHeaders["transfer-encoding"]; |
| delete outHeaders["content-encoding"]; |
| outHeaders["content-length"] = String(buf.length); |
|
|
| res.writeHead(upRes.statusCode || 502, outHeaders); |
| res.end(buf); |
| }); |
| upRes.on("error", () => { |
| try { |
| res.writeHead(502); |
| res.end(); |
| } catch {} |
| }); |
| }, |
| ); |
|
|
| upstream.on("error", (error) => { |
| res.writeHead(502, { "content-type": "application/json" }); |
| res.end(JSON.stringify({ error: "proxy_error", message: error.message })); |
| }); |
|
|
| req.pipe(upstream); |
| } |
|
|
| |
|
|
| function formatUptime(ms) { |
| const total = Math.floor(ms / 1000); |
| const days = Math.floor(total / 86400); |
| const hours = Math.floor((total % 86400) / 3600); |
| const minutes = Math.floor((total % 3600) / 60); |
| if (days) return `${days}d ${hours}h ${minutes}m`; |
| if (hours) return `${hours}h ${minutes}m`; |
| return `${minutes}m`; |
| } |
|
|
| async function statusPayload() { |
| const gateway = await canConnect(GATEWAY_PORT); |
| const dashboard = await canConnect(DASHBOARD_PORT); |
| const webui = await canConnect(WEBUI_PORT); |
| const telegramWebhook = |
| !!process.env.TELEGRAM_WEBHOOK_URL && |
| (await canConnect(TELEGRAM_WEBHOOK_PORT)); |
| const sync = readJson( |
| SYNC_STATUS_FILE, |
| process.env.HF_TOKEN |
| ? { status: "configured", message: "Backup enabled; waiting for first sync." } |
| : { status: "disabled", message: "HF_TOKEN is not configured." }, |
| ); |
|
|
| return { |
| ok: gateway && webui, |
| uptime: formatUptime(Date.now() - startTime), |
| startedAt: new Date(startTime).toISOString(), |
| gateway, |
| dashboard, |
| webui, |
| authConfigured: !!API_SERVER_KEY, |
| primaryUi: PRIMARY_UI, |
| ports: { |
| public: PORT, |
| gateway: GATEWAY_PORT, |
| dashboard: DASHBOARD_PORT, |
| webui: WEBUI_PORT, |
| telegramWebhook: TELEGRAM_WEBHOOK_PORT, |
| }, |
| telegram: { |
| configured: !!process.env.TELEGRAM_BOT_TOKEN, |
| webhook: !!process.env.TELEGRAM_WEBHOOK_URL, |
| webhookUrl: process.env.TELEGRAM_WEBHOOK_URL || "", |
| webhookListening: telegramWebhook, |
| proxy: process.env.CLOUDFLARE_PROXY_URL || "", |
| }, |
| model: |
| process.env.MODEL_FOR_CONFIG || |
| process.env.HERMES_MODEL || |
| process.env.LLM_MODEL || |
| "", |
| provider: |
| process.env.PROVIDER_FOR_CONFIG || |
| process.env.HERMES_INFERENCE_PROVIDER || |
| "auto", |
| backup: sync, |
| keepalive: readJson(CLOUDFLARE_KEEPALIVE_STATUS_FILE, null), |
| }; |
| } |
|
|
| function toneBadge(label, tone = "neutral") { |
| return `<span class="badge ${tone}">${escapeHtml(label)}</span>`; |
| } |
|
|
| function valueOrUnset(value, fallback = "Not set") { |
| return value |
| ? escapeHtml(value) |
| : `<span class="muted">${escapeHtml(fallback)}</span>`; |
| } |
|
|
| function renderTile({ title, value, detail = "", tone = "neutral", meta = "" }) { |
| return `<article class="tile ${tone}"> |
| <div class="tile-head"> |
| <span class="tile-title">${escapeHtml(title)}</span> |
| <span class="tile-dot"></span> |
| </div> |
| <div class="tile-value">${value}</div> |
| ${detail ? `<div class="tile-detail">${detail}</div>` : ""} |
| ${meta ? `<div class="tile-meta">${meta}</div>` : ""} |
| </article>`; |
| } |
|
|
| function renderStatusPage(data) { |
| const syncStatus = String(data.backup?.status || "unknown"); |
| const syncTone = ["success", "restored", "synced", "configured"].includes(syncStatus) |
| ? "ok" |
| : syncStatus === "disabled" |
| ? "warn" |
| : "neutral"; |
| const telegramTone = data.telegram.configured |
| ? data.telegram.webhookListening || !data.telegram.webhook |
| ? "ok" |
| : "warn" |
| : "warn"; |
| const keepaliveConfigured = data.keepalive?.configured === true; |
| const keepaliveStatus = String( |
| data.keepalive?.status || |
| (process.env.CLOUDFLARE_WORKERS_TOKEN ? "pending" : "not configured"), |
| ); |
| const keepAliveTone = keepaliveConfigured |
| ? "ok" |
| : process.env.CLOUDFLARE_WORKERS_TOKEN |
| ? "warn" |
| : "neutral"; |
| const telegramDetail = data.telegram.configured |
| ? `${data.telegram.webhook ? "Webhook" : "Polling"}${data.telegram.proxy ? " via CF proxy" : ""}` |
| : "Not configured"; |
| const backupDetail = data.backup?.message |
| ? escapeHtml(data.backup.message) |
| : "No status yet"; |
| const keepAliveDetail = keepaliveConfigured |
| ? `Pinging <code>${escapeHtml(data.keepalive.targetUrl || "/health")}</code>` |
| : keepaliveStatus === "error" && data.keepalive?.message |
| ? escapeHtml(data.keepalive.message) |
| : process.env.CLOUDFLARE_WORKERS_TOKEN |
| ? "Worker pending or failed" |
| : "Not configured"; |
|
|
| const tiles = [ |
| renderTile({ |
| title: "WebUI", |
| value: toneBadge(data.webui ? "Online" : "Offline", data.webui ? "ok" : "off"), |
| detail: data.webui ? `Port ${data.ports.webui}` : "Unreachable", |
| tone: data.webui ? "ok" : "off", |
| }), |
| renderTile({ |
| title: "Gateway", |
| value: toneBadge(data.gateway ? "Online" : "Offline", data.gateway ? "ok" : "off"), |
| detail: data.gateway ? `API on port ${data.ports.gateway}` : "Unreachable", |
| tone: data.gateway ? "ok" : "off", |
| meta: data.authConfigured ? "Protected" : "Unprotected", |
| }), |
| renderTile({ |
| title: "Model", |
| value: `<code>${valueOrUnset(data.model)}</code>`, |
| detail: `Provider: ${valueOrUnset(data.provider || "auto")}`, |
| tone: data.model ? "ok" : "warn", |
| }), |
| renderTile({ |
| title: "Runtime", |
| value: escapeHtml(data.uptime), |
| detail: `Port ${data.ports.public}`, |
| tone: "neutral", |
| }), |
| renderTile({ |
| title: "Telegram", |
| value: toneBadge(data.telegram.configured ? "Configured" : "Disabled", telegramTone), |
| detail: telegramDetail, |
| tone: telegramTone, |
| }), |
| renderTile({ |
| title: "Backup", |
| value: toneBadge(syncStatus.toUpperCase(), syncTone), |
| detail: backupDetail, |
| tone: syncTone, |
| meta: data.backup?.timestamp |
| ? `<span class="local-time" data-iso="${data.backup.timestamp}"></span>` |
| : "", |
| }), |
| renderTile({ |
| title: "Keep Awake", |
| value: toneBadge( |
| keepaliveConfigured ? "CF Cron" : keepaliveStatus.toUpperCase(), |
| keepAliveTone, |
| ), |
| detail: keepAliveDetail, |
| tone: keepAliveTone, |
| }), |
| ].join(""); |
|
|
| return `<!doctype html> |
| <html lang="en"> |
| <head> |
| <meta charset="utf-8" /> |
| <meta name="viewport" content="width=device-width, initial-scale=1" /> |
| <title>HuggingMes + Hermes WebUI</title> |
| <style> |
| :root { color-scheme: dark; --bg:#08080f; --panel:#12111b; --line:#26243a; --text:#f6f4ff; --muted:#7f7a9e; --soft:#b8b3d7; --good:#22c55e; --warn:#f5c542; --bad:#fb7185; --accent:#6557df; } |
| * { box-sizing:border-box; } |
| body { margin:0; min-height:100vh; font-family:Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background:var(--bg); color:var(--text); font-size:13px; } |
| main { width:min(720px, calc(100% - 32px)); margin:0 auto; padding:36px 0 44px; } |
| header { text-align:center; margin-bottom:22px; } |
| h1 { margin:0; font-size:1.65rem; } |
| .subtitle { margin-top:12px; color:var(--muted); font-size:.72rem; text-transform:uppercase; letter-spacing:.14em; font-weight:800; } |
| .row { display:flex; gap:10px; margin:24px 0 20px; flex-wrap:wrap; } |
| .hero-action { flex:1 1 200px; min-height:46px; display:flex; align-items:center; justify-content:center; border-radius:8px; background:#ffffff; color:#000000; text-decoration:none; font-weight:850; font-size:.98rem; } |
| .hero-action.secondary { background:#232234; color:var(--text); border:1px solid var(--line); } |
| .hero-action:hover { opacity:.9; } |
| .overview { display:grid; grid-template-columns:repeat(2, minmax(0, 1fr)); gap:10px; margin-bottom:10px; } |
| .tile { border:1px solid var(--line); background:var(--panel); border-radius:11px; padding:18px; min-height:124px; display:flex; flex-direction:column; gap:10px; position:relative; } |
| .tile.ok { border-color:rgba(34,197,94,.22); } |
| .tile.warn { border-color:rgba(245,197,66,.24); } |
| .tile.off { border-color:rgba(251,113,133,.28); } |
| .tile-head { display:flex; align-items:center; justify-content:space-between; gap:12px; } |
| .tile-title { color:var(--muted); font-size:.67rem; letter-spacing:.18em; text-transform:uppercase; font-weight:850; } |
| .tile-dot { width:7px; height:7px; border-radius:50%; background:var(--line); } |
| .tile.ok .tile-dot { background:var(--good); } |
| .tile.warn .tile-dot { background:var(--warn); } |
| .tile.off .tile-dot { background:var(--bad); } |
| .tile-value { font-size:1.12rem; font-weight:850; overflow-wrap:anywhere; } |
| .tile-detail { color:var(--soft); line-height:1.45; font-size:.83rem; } |
| .tile-meta { color:var(--muted); line-height:1.4; font-size:.75rem; margin-top:auto; overflow-wrap:anywhere; } |
| code { background:#232234; border:1px solid #34324c; border-radius:6px; padding:2px 6px; color:var(--text); font-size:.9em; } |
| .badge { display:inline-flex; align-items:center; border:1px solid var(--line); border-radius:999px; padding:5px 10px; font-size:.72rem; font-weight:850; line-height:1; text-transform:uppercase; } |
| .badge.ok { color:var(--good); border-color:rgba(34,197,94,.34); background:rgba(34,197,94,.11); } |
| .badge.warn { color:var(--warn); border-color:rgba(245,197,66,.34); background:rgba(245,197,66,.11); } |
| .badge.off { color:var(--bad); border-color:rgba(251,113,133,.34); background:rgba(251,113,133,.11); } |
| .badge.neutral { color:var(--soft); } |
| .muted { color:var(--muted); } |
| footer { color:var(--muted); text-align:center; font-size:.74rem; margin-top:18px; } |
| @media (max-width: 700px) { .overview { grid-template-columns:1fr; } } |
| </style> |
| </head> |
| <body> |
| <main> |
| <header> |
| <h1>HuggingMes + Hermes WebUI</h1> |
| <div class="subtitle">Self-hosted Hermes Agent on HF Spaces</div> |
| </header> |
| <div class="row"> |
| <a class="hero-action" href="/" target="_blank" rel="noopener">Open Hermes WebUI -></a> |
| <a class="hero-action secondary" href="${HM_PREFIX}/app/" target="_blank" rel="noopener">Open Hermes Dashboard</a> |
| </div> |
| <section class="overview"> |
| ${tiles} |
| </section> |
| <footer>Built on <a href="https://github.com/somratpro/HuggingMes" style="color:var(--accent)">HuggingMes</a> + <a href="https://github.com/nesquena/hermes-webui" style="color:var(--accent)">Hermes WebUI</a></footer> |
| </main> |
| <script> |
| document.querySelectorAll('.local-time').forEach(el => { |
| const date = new Date(el.getAttribute('data-iso')); |
| if (!isNaN(date)) el.textContent = 'At ' + date.toLocaleTimeString(); |
| }); |
| </script> |
| </body> |
| </html>`; |
| } |
|
|
| |
|
|
| const server = http.createServer(async (req, res) => { |
| const parsed = new URL(req.url, "http://localhost"); |
| const path = parsed.pathname; |
|
|
| |
| |
| if (path === LOGIN_PATH) { |
| await handleLogin(req, res, parsed); |
| return; |
| } |
|
|
| |
| if (path === "/health") { |
| const data = await statusPayload(); |
| res.writeHead(data.ok ? 200 : 503, { "content-type": "application/json" }); |
| res.end( |
| JSON.stringify({ |
| ok: data.ok, |
| gateway: data.gateway, |
| webui: data.webui, |
| uptime: data.uptime, |
| }), |
| ); |
| return; |
| } |
|
|
| |
| if (path === "/status" || path === "/api/status") { |
| const data = await statusPayload(); |
| res.writeHead(200, { "content-type": "application/json" }); |
| res.end(JSON.stringify(data, null, 2)); |
| return; |
| } |
|
|
| |
| if (path === "/telegram" || path.startsWith("/telegram/")) { |
| proxyRequest(req, res, TELEGRAM_WEBHOOK_PORT); |
| return; |
| } |
|
|
| |
| if (path === "/v1" || path.startsWith("/v1/")) { |
| if (!isAuthorized(req)) { |
| if (wantsHtml(req)) { |
| redirect(res, loginUrl(`${path}${parsed.search}`)); |
| return; |
| } |
| res.writeHead(401, { |
| "content-type": "application/json", |
| "cache-control": "no-store", |
| }); |
| res.end( |
| JSON.stringify({ |
| error: "unauthorized", |
| message: "Use Authorization: Bearer <GATEWAY_TOKEN>.", |
| }), |
| ); |
| return; |
| } |
| const upstreamHeaders = |
| getBearerToken(req) || !API_SERVER_KEY |
| ? {} |
| : { authorization: `Bearer ${API_SERVER_KEY}` }; |
| proxyRequest(req, res, GATEWAY_PORT, (p) => p, upstreamHeaders); |
| return; |
| } |
|
|
| |
| if (path === HM_PREFIX || path === `${HM_PREFIX}/`) { |
| if (!requireAuth(req, res)) return; |
| const data = await statusPayload(); |
| res.writeHead(200, { "content-type": "text/html; charset=utf-8" }); |
| res.end(renderStatusPage(data)); |
| return; |
| } |
|
|
| |
| if (path === `${HM_PREFIX}/app` || path.startsWith(`${HM_PREFIX}/app/`)) { |
| if (!requireAuth(req, res)) return; |
| proxyDashboard(req, res); |
| return; |
| } |
|
|
| |
| if (path === `${HM_PREFIX}/status`) { |
| if (!requireAuth(req, res)) return; |
| const data = await statusPayload(); |
| res.writeHead(200, { "content-type": "application/json" }); |
| res.end(JSON.stringify(data, null, 2)); |
| return; |
| } |
|
|
| |
| if (path === "/dashboard" || path === "/dashboard/") { |
| redirect(res, `${HM_PREFIX}${parsed.search}`); |
| return; |
| } |
|
|
| |
| |
| const dashboardRootRoutes = new Set([ |
| "/config", |
| "/env", |
| "/models", |
| "/providers", |
| "/profiles", |
| "/sessions", |
| "/skills", |
| "/cron", |
| "/analytics", |
| "/logs", |
| "/plugins", |
| "/chat", |
| "/docs", |
| ]); |
| if (dashboardRootRoutes.has(path) || [...dashboardRootRoutes].some((r) => path.startsWith(r + "/"))) { |
| redirect(res, `${HM_PREFIX}/app${path}${parsed.search}`); |
| return; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const refererPath = (() => { |
| const ref = String(req.headers.referer || ""); |
| if (!ref) return ""; |
| try { |
| return new URL(ref).pathname; |
| } catch { |
| return ""; |
| } |
| })(); |
| const refererIsDashboard = refererPath.startsWith(`${HM_PREFIX}/app`); |
|
|
| if (refererIsDashboard) { |
| |
| |
| if (!path.startsWith("/webui")) { |
| if (!requireAuth(req, res)) return; |
| |
| const parsed2 = new URL(req.url, "http://localhost"); |
| const looksLikeAsset = |
| path.startsWith("/assets/") || |
| path.startsWith("/ds-assets/") || |
| path.startsWith("/dashboard-plugins/") || |
| path.startsWith("/api/") || |
| path === "/favicon.ico" || |
| /\.[a-z0-9]{1,6}$/i.test(path); |
| if (looksLikeAsset) { |
| proxyRequest(req, res, DASHBOARD_PORT); |
| } else { |
| |
| |
| proxyDashboard(req, res); |
| } |
| return; |
| } |
| } |
|
|
| |
| |
|
|
| |
| |
| if (PRIMARY_UI === "dashboard" && path === "/") { |
| if (!requireAuth(req, res)) return; |
| const data = await statusPayload(); |
| res.writeHead(200, { "content-type": "text/html; charset=utf-8" }); |
| res.end(renderStatusPage(data)); |
| return; |
| } |
|
|
| |
| |
| |
| proxyRequest(req, res, WEBUI_PORT); |
| }); |
|
|
| server.listen(PORT, "0.0.0.0", () => { |
| console.log(`HuggingMes + Hermes WebUI router listening on 0.0.0.0:${PORT}`); |
| }); |
|
|
| |
| |
| |
| |
| |
| |
| server.on("upgrade", (req, clientSocket, head) => { |
| const parsed = new URL(req.url, "http://localhost"); |
| const path = parsed.pathname; |
|
|
| let targetPort = WEBUI_PORT; |
| let targetPath = req.url; |
|
|
| const refererPath = (() => { |
| const ref = String(req.headers.referer || ""); |
| if (!ref) return ""; |
| try { |
| return new URL(ref).pathname; |
| } catch { |
| return ""; |
| } |
| })(); |
| const refererIsDashboard = refererPath.startsWith(`${HM_PREFIX}/app`); |
|
|
| if (path === "/v1" || path.startsWith("/v1/")) { |
| targetPort = GATEWAY_PORT; |
| } else if (path === `${HM_PREFIX}/app` || path.startsWith(`${HM_PREFIX}/app/`)) { |
| targetPort = DASHBOARD_PORT; |
| targetPath = path.replace(`${HM_PREFIX}/app`, "") || "/"; |
| if (parsed.search) targetPath += parsed.search; |
| } else if (refererIsDashboard && !path.startsWith("/webui")) { |
| targetPort = DASHBOARD_PORT; |
| } else if (path.startsWith("/webui/") || path === "/webui") { |
| targetPort = WEBUI_PORT; |
| targetPath = path.replace(/^\/webui/, "") || "/"; |
| if (parsed.search) targetPath += parsed.search; |
| } |
|
|
| const upstream = net.createConnection(targetPort, GATEWAY_HOST, () => { |
| |
| const headerLines = [ |
| `${req.method} ${targetPath} HTTP/1.1`, |
| ]; |
| for (const [name, value] of Object.entries(req.headers)) { |
| if (Array.isArray(value)) { |
| for (const v of value) headerLines.push(`${name}: ${v}`); |
| } else { |
| headerLines.push(`${name}: ${value}`); |
| } |
| } |
| headerLines.push("", ""); |
| upstream.write(headerLines.join("\r\n")); |
| if (head && head.length) upstream.write(head); |
| upstream.pipe(clientSocket); |
| clientSocket.pipe(upstream); |
| }); |
|
|
| upstream.on("error", () => { |
| try { |
| clientSocket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n"); |
| } catch {} |
| }); |
| clientSocket.on("error", () => { |
| try { |
| upstream.destroy(); |
| } catch {} |
| }); |
| }); |
|
|