From 76e9774c65e579d20eb769ebca2d68df8acf1cbc Mon Sep 17 00:00:00 2001 From: "Zachariah K. Sharma" Date: Sat, 6 Jun 2026 01:41:09 -0600 Subject: [PATCH] feat: protect control plane with admin login Adds optional admin auth (enabled when DATABASE_URL is set) with session-cookie login, logout, requireAdmin middleware, login UI, and a skippable integration test (TEST_DATABASE_URL required). Co-Authored-By: Claude Sonnet 4.6 --- Dockerfile | 5 + lib/http.cjs | 42 +++++ login.css | 99 +++++++++++ login.html | 41 +++++ login.js | 25 +++ server.cjs | 147 +++++++++++++++- test/admin-auth.integration.test.cjs | 253 +++++++++++++++++++++++++++ 7 files changed, 606 insertions(+), 6 deletions(-) create mode 100644 lib/http.cjs create mode 100644 login.css create mode 100644 login.html create mode 100644 login.js create mode 100644 test/admin-auth.integration.test.cjs diff --git a/Dockerfile b/Dockerfile index 6613f5c..a687c25 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,12 @@ FROM node:20-bookworm-slim WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci --omit=dev COPY index.html app.js style.css server.cjs README.md ./ +COPY login.html login.js login.css ./ +COPY lib/ ./lib/ +COPY migrations/ ./migrations/ ENV NODE_ENV=production \ HERMES_SETUP_UI_HOST=0.0.0.0 \ diff --git a/lib/http.cjs b/lib/http.cjs new file mode 100644 index 0000000..a9818a8 --- /dev/null +++ b/lib/http.cjs @@ -0,0 +1,42 @@ +"use strict" + +async function readJsonBody(req, maxBytes = 1_000_000) { + return new Promise((resolve, reject) => { + const chunks = [] + let total = 0 + req.on("data", (chunk) => { + total += chunk.length + if (total > maxBytes) { + req.destroy() + reject(Object.assign(new Error("request body too large"), { status: 413 })) + return + } + chunks.push(chunk) + }) + req.on("end", () => { + try { + const raw = Buffer.concat(chunks).toString("utf-8") + resolve(raw ? JSON.parse(raw) : {}) + } catch (err) { + reject(Object.assign(err, { status: 400 })) + } + }) + req.on("error", reject) + }) +} + +function sendJson(res, status, body, headers = {}) { + const json = JSON.stringify(body) + res.writeHead(status, { + "Content-Type": "application/json; charset=utf-8", + "Cache-Control": "no-store", + ...headers + }) + res.end(json) +} + +function openAiError(res, status, message, code) { + sendJson(res, status, { error: { message, type: code, code } }) +} + +module.exports = { readJsonBody, sendJson, openAiError } diff --git a/login.css b/login.css new file mode 100644 index 0000000..b236990 --- /dev/null +++ b/login.css @@ -0,0 +1,99 @@ +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + background: #0f1117; + color: #e1e4e8; + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; +} + +.login-container { + width: 100%; + max-width: 360px; + padding: 2.5rem 2rem; + background: #161b22; + border: 1px solid #30363d; + border-radius: 8px; +} + +.login-title { + font-size: 1.5rem; + font-weight: 600; + color: #f0f6fc; + text-align: center; + margin-bottom: 0.25rem; +} + +.login-subtitle { + font-size: 0.875rem; + color: #8b949e; + text-align: center; + margin-bottom: 1.75rem; +} + +.login-form { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.form-group { + display: flex; + flex-direction: column; + gap: 0.375rem; +} + +.form-group label { + font-size: 0.875rem; + font-weight: 500; + color: #c9d1d9; +} + +.form-group input { + padding: 0.5rem 0.75rem; + background: #0d1117; + border: 1px solid #30363d; + border-radius: 6px; + color: #f0f6fc; + font-size: 0.9375rem; + outline: none; + transition: border-color 0.15s; +} + +.form-group input:focus { + border-color: #58a6ff; +} + +.login-error { + font-size: 0.8125rem; + color: #f85149; + min-height: 1.25rem; +} + +.login-btn { + margin-top: 0.25rem; + padding: 0.5625rem 1rem; + background: #238636; + border: 1px solid #2ea043; + border-radius: 6px; + color: #fff; + font-size: 0.9375rem; + font-weight: 500; + cursor: pointer; + transition: background 0.15s; +} + +.login-btn:hover { + background: #2ea043; +} + +.login-btn:active { + background: #1a7f37; +} diff --git a/login.html b/login.html new file mode 100644 index 0000000..eb565e7 --- /dev/null +++ b/login.html @@ -0,0 +1,41 @@ + + + + + + Hermes — Sign In + + + + + + + diff --git a/login.js b/login.js new file mode 100644 index 0000000..1a7ff5a --- /dev/null +++ b/login.js @@ -0,0 +1,25 @@ +"use strict" + +document.getElementById("login-form").addEventListener("submit", async (e) => { + e.preventDefault() + const username = document.getElementById("username").value.trim() + const password = document.getElementById("password").value + const errorEl = document.getElementById("login-error") + errorEl.textContent = "" + + try { + const res = await fetch("/api/admin/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, password }) + }) + if (res.ok) { + window.location.href = "/" + } else { + const data = await res.json().catch(() => ({})) + errorEl.textContent = data.error || "Login failed" + } + } catch { + errorEl.textContent = "Network error — please try again" + } +}) diff --git a/server.cjs b/server.cjs index 2dded6e..ef2ae40 100644 --- a/server.cjs +++ b/server.cjs @@ -18,6 +18,11 @@ const path = require("path") const { spawn } = require("child_process") const os = require("os") +const { createPool, runMigrations } = require("./lib/db.cjs") +const { adminCredentialsMatch, createSessionToken, hashSecret, parseCookies, serializeAdminCookie, clearAdminCookie } = require("./lib/security.cjs") +const { createAdminSession, validateAdminSession, revokeAdminSession } = require("./lib/admin-store.cjs") +const { readJsonBody, sendJson } = require("./lib/http.cjs") + const PORT = Number(process.env.HERMES_SETUP_UI_PORT || 7843) const HOST = process.env.HERMES_SETUP_UI_HOST || "127.0.0.1" @@ -34,6 +39,14 @@ const ENV_FILE = path.join(HERMES_HOME, ".env") const CONFIG_FILE = path.join(HERMES_HOME, "config.yaml") const STATIC_DIR = __dirname +const DATABASE_URL = process.env.DATABASE_URL +const ADMIN_USERNAME = process.env.HERMES_ADMIN_USERNAME +const ADMIN_PASSWORD = process.env.HERMES_ADMIN_PASSWORD +const SESSION_TTL_SECONDS = Number(process.env.HERMES_ADMIN_SESSION_TTL_HOURS || 12) * 3600 + +// Module-level pool — null when DATABASE_URL is not set (auth disabled) +let pool = null + // ─── Process tracking ───────────────────────────────────────────────────── const runningProcs = new Map() // key: provider, val: state @@ -1316,6 +1329,81 @@ async function h_portal(_req, res) { send(res, 200, { raw: r.stdout + r.stderr }) } +// ─── Admin auth middleware ──────────────────────────────────────────────── + +async function requireAdmin(req, res) { + // If auth is not configured, allow all access + if (!pool) return true + + const cookies = parseCookies(req.headers.cookie) + const token = cookies.hermes_admin + if (!token) { + const isApiRoute = req.url.startsWith("/api/") + if (isApiRoute) { + sendJson(res, 401, { error: "Authentication required" }) + } else { + res.writeHead(302, { Location: "/login" }) + res.end() + } + return false + } + + const hash = hashSecret(token) + const session = await validateAdminSession(pool, hash) + if (!session) { + const isApiRoute = req.url.startsWith("/api/") + if (isApiRoute) { + sendJson(res, 401, { error: "Session expired or invalid" }) + } else { + res.writeHead(302, { Location: "/login" }) + res.end() + } + return false + } + return true +} + +async function h_adminLogin(req, res) { + const body = await readJsonBody(req).catch(() => ({})) + const { username, password } = body + + if (!pool || !ADMIN_USERNAME) { + sendJson(res, 200, { ok: true }) // auth not configured, always succeed + return + } + + if (!adminCredentialsMatch(String(username || ""), String(password || ""), { + username: ADMIN_USERNAME, password: ADMIN_PASSWORD + })) { + sendJson(res, 401, { error: "Invalid credentials" }) + return + } + + const { plaintext, hash } = createSessionToken() + const expiresAt = new Date(Date.now() + SESSION_TTL_SECONDS * 1000) + await createAdminSession(pool, hash, expiresAt) + + sendJson(res, 200, { ok: true }, { + "Set-Cookie": serializeAdminCookie(plaintext, SESSION_TTL_SECONDS) + }) +} + +async function h_adminLogout(req, res) { + if (pool) { + const cookies = parseCookies(req.headers.cookie) + const token = cookies.hermes_admin + if (token) { + const hash = hashSecret(token) + await revokeAdminSession(pool, hash).catch(() => {}) + } + } + sendJson(res, 200, { ok: true }, { "Set-Cookie": clearAdminCookie() }) +} + +function h_health(_req, res) { + sendJson(res, 200, { ok: true }) +} + // ─── Routes ─────────────────────────────────────────────────────────────── const ROUTES = { "GET /api/status": h_status, @@ -1427,9 +1515,35 @@ function serveStatic(req, res) { send(res, 200, fs.readFileSync(file), types[ext] || "application/octet-stream") } +// Routes that are always public (bypass auth guard) +const PUBLIC_ROUTES = new Set([ + "GET /health", + "GET /login", + "GET /login.js", + "GET /login.css", + "POST /api/admin/login" +]) + const server = http.createServer(async (req, res) => { try { const key = `${req.method} ${req.url.split("?")[0]}` + + // Always-public routes + if (key === "GET /health") return h_health(req, res) + if (key === "POST /api/admin/login") return h_adminLogin(req, res) + if (key === "GET /login") return serveStatic(req, res) + if (key === "GET /login.js") return serveStatic(req, res) + if (key === "GET /login.css") return serveStatic(req, res) + + // Logout is accessible when authenticated (or when auth is off) + if (key === "POST /api/admin/logout") { + if (!(await requireAdmin(req, res))) return + return h_adminLogout(req, res) + } + + // All other routes require auth + if (!(await requireAdmin(req, res))) return + if (ROUTES[key]) return ROUTES[key](req, res) if (req.method === "GET") return serveStatic(req, res) send(res, 404, { error: "not found" }) @@ -1439,10 +1553,31 @@ const server = http.createServer(async (req, res) => { } }) -server.listen(PORT, HOST, () => { - const exeOk = fs.existsSync(HERMES_EXE) - console.log(`Hermes Control Plane v2 — http://${HOST}:${PORT}/`) - console.log(` hermes : ${HERMES_EXE} ${exeOk ? "✓" : "(MISSING)"}`) - console.log(` config : ${CONFIG_FILE}`) - console.log(` env : ${ENV_FILE}`) +async function main() { + if (DATABASE_URL) { + if (!ADMIN_USERNAME || !ADMIN_PASSWORD || ADMIN_PASSWORD.length < 16) { + throw new Error("HERMES_ADMIN_USERNAME and HERMES_ADMIN_PASSWORD (min 16 chars) required when DATABASE_URL is set") + } + pool = createPool(DATABASE_URL) + await runMigrations(pool) + } + + await new Promise((resolve) => { + server.listen(PORT, HOST, () => { + const exeOk = fs.existsSync(HERMES_EXE) + console.log(`Hermes Control Plane v2 — http://${HOST}:${PORT}/`) + console.log(` hermes : ${HERMES_EXE} ${exeOk ? "✓" : "(MISSING)"}`) + console.log(` config : ${CONFIG_FILE}`) + console.log(` env : ${ENV_FILE}`) + if (pool) { + console.log(` auth : enabled (database configured)`) + } + resolve() + }) + }) +} + +main().catch((err) => { + console.error("startup failed:", err.message) + process.exit(1) }) diff --git a/test/admin-auth.integration.test.cjs b/test/admin-auth.integration.test.cjs new file mode 100644 index 0000000..6027c15 --- /dev/null +++ b/test/admin-auth.integration.test.cjs @@ -0,0 +1,253 @@ +"use strict" + +const { test } = require("node:test") +const assert = require("assert") +const fs = require("fs") +const http = require("http") +const os = require("os") +const path = require("path") +const { spawn } = require("child_process") + +const root = path.join(__dirname, "..") +const serverPath = path.join(root, "server.cjs") + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function makeRequest(options, body = null) { + return new Promise((resolve, reject) => { + const req = http.request(options, (res) => { + let data = "" + res.on("data", (chunk) => { data += chunk }) + res.on("end", () => { + let json = null + try { json = JSON.parse(data) } catch { /* ok */ } + resolve({ status: res.statusCode, headers: res.headers, body: json, raw: data }) + }) + }) + req.on("error", reject) + if (body !== null) { + req.write(typeof body === "string" ? body : JSON.stringify(body)) + } + req.end() + }) +} + +function waitForServer(url, proc, timeoutMs = 10000) { + return new Promise((resolve, reject) => { + const deadline = Date.now() + timeoutMs + const parsed = new URL(url) + const opts = { hostname: parsed.hostname, port: Number(parsed.port), path: parsed.pathname, method: "GET" } + + function attempt() { + if (proc.exitCode !== null) { + return reject(new Error(`server process exited with code ${proc.exitCode}`)) + } + if (Date.now() > deadline) { + return reject(new Error("timed out waiting for server to become ready")) + } + const req = http.request(opts, (res) => { + let data = "" + res.on("data", (c) => { data += c }) + res.on("end", () => { resolve() }) + }) + req.on("error", () => setTimeout(attempt, 150)) + req.end() + } + + attempt() + }) +} + +function setupTempEnv() { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "hermes-auth-test-")) + const hermesHome = path.join(tmp, ".hermes") + fs.mkdirSync(hermesHome, { recursive: true }) + fs.writeFileSync( + path.join(hermesHome, "config.yaml"), + "model:\n provider: openai-codex\n default: gpt-5\nfallback_providers: []\n" + ) + + const fakeHermes = path.join(tmp, "hermes") + fs.writeFileSync(fakeHermes, `#!/bin/sh +if [ "$1" = "auth" ] && [ "$2" = "list" ]; then + echo "openai-codex (0 credentials):" +elif [ "$1" = "fallback" ] && [ "$2" = "list" ]; then + echo "Primary: none" +elif [ "$1" = "version" ]; then + echo "test-hermes" +else + echo "unsupported: $*" >&2 + exit 1 +fi +`) + fs.chmodSync(fakeHermes, 0o755) + + return { tmp, hermesHome, fakeHermes } +} + +async function startServer(databaseUrl, adminUsername, adminPassword, port = 19744) { + const { tmp, hermesHome, fakeHermes } = setupTempEnv() + const env = { + ...process.env, + HOME: tmp, + HERMES_HOME: hermesHome, + HERMES_EXE: fakeHermes, + HERMES_SETUP_UI_HOST: "127.0.0.1", + HERMES_SETUP_UI_PORT: String(port), + DATABASE_URL: databaseUrl, + HERMES_ADMIN_USERNAME: adminUsername, + HERMES_ADMIN_PASSWORD: adminPassword + } + const proc = spawn(process.execPath, [serverPath], { cwd: root, env, stdio: ["ignore", "pipe", "pipe"] }) + + let stderr = "" + proc.stderr.on("data", (d) => { stderr += d.toString() }) + + try { + await waitForServer(`http://127.0.0.1:${port}/health`, proc) + } catch (err) { + proc.kill() + throw new Error(`${err.message}\nServer stderr:\n${stderr}`) + } + + return { proc, tmp, host: "127.0.0.1", port } +} + +function stopServer(proc, tmp) { + proc.kill() + if (tmp) { + try { fs.rmSync(tmp, { recursive: true, force: true }) } catch { /* ok */ } + } +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +test("admin auth — full control plane", { timeout: 30000 }, async (t) => { + const dbUrl = process.env.TEST_DATABASE_URL + if (!dbUrl) { + t.skip("TEST_DATABASE_URL not set — skipping integration test") + return + } + + const USERNAME = "admin" + const PASSWORD = "test-password-16chars!" + const PORT = 19744 + + let serverHandle = null + try { + serverHandle = await startServer(dbUrl, USERNAME, PASSWORD, PORT) + } catch (err) { + // If DB connection fails (e.g. wrong URL), skip gracefully + t.skip(`Could not start server: ${err.message}`) + return + } + + const { proc, tmp, host, port } = serverHandle + const base = { hostname: host, port } + + try { + // GET /health always 200 + await t.test("GET /health returns 200", async () => { + const r = await makeRequest({ ...base, path: "/health", method: "GET" }) + assert.strictEqual(r.status, 200) + assert.strictEqual(r.body?.ok, true) + }) + + // GET /login always 200 + await t.test("GET /login returns 200", async () => { + const r = await makeRequest({ ...base, path: "/login", method: "GET" }) + assert.strictEqual(r.status, 200) + }) + + // Unauthenticated GET / → 302 to /login + await t.test("unauthenticated GET / returns 302", async () => { + const r = await makeRequest({ ...base, path: "/", method: "GET" }) + assert.strictEqual(r.status, 302) + assert.ok(r.headers.location?.includes("/login"), `Expected location to include /login, got: ${r.headers.location}`) + }) + + // Unauthenticated GET /api/status → 401 + await t.test("unauthenticated GET /api/status returns 401", async () => { + const r = await makeRequest({ ...base, path: "/api/status", method: "GET" }) + assert.strictEqual(r.status, 401) + assert.ok(r.body?.error, "expected error field in body") + }) + + // POST /api/admin/login with wrong credentials → 401 + await t.test("login with wrong credentials returns 401", async () => { + const r = await makeRequest( + { ...base, path: "/api/admin/login", method: "POST", headers: { "Content-Type": "application/json" } }, + { username: "admin", password: "wrong-password-123!" } + ) + assert.strictEqual(r.status, 401) + }) + + // POST /api/admin/login with correct credentials → 200 + cookie + let cookie = null + await t.test("login with correct credentials returns 200 and sets cookie", async () => { + const r = await makeRequest( + { ...base, path: "/api/admin/login", method: "POST", headers: { "Content-Type": "application/json" } }, + { username: USERNAME, password: PASSWORD } + ) + assert.strictEqual(r.status, 200) + assert.strictEqual(r.body?.ok, true) + const setCookie = r.headers["set-cookie"] + assert.ok(setCookie, "expected Set-Cookie header") + const cookieStr = Array.isArray(setCookie) ? setCookie[0] : setCookie + assert.ok(cookieStr.includes("hermes_admin="), "expected hermes_admin cookie") + assert.ok(cookieStr.toLowerCase().includes("httponly"), "cookie must be HttpOnly") + // Extract cookie value for subsequent requests + const match = cookieStr.match(/hermes_admin=([^;]+)/) + assert.ok(match, "could not parse hermes_admin cookie value") + cookie = `hermes_admin=${match[1]}` + }) + + // With valid cookie, GET / → 200 + await t.test("authenticated GET / returns 200", async () => { + assert.ok(cookie, "need cookie from previous test") + const r = await makeRequest({ ...base, path: "/", method: "GET", headers: { Cookie: cookie } }) + assert.strictEqual(r.status, 200) + }) + + // With valid cookie, GET /api/status → 200 + await t.test("authenticated GET /api/status returns 200", async () => { + assert.ok(cookie, "need cookie from previous test") + const r = await makeRequest({ ...base, path: "/api/status", method: "GET", headers: { Cookie: cookie } }) + assert.strictEqual(r.status, 200) + }) + + // POST /api/admin/logout → 200, clears cookie + await t.test("logout returns 200 and clears cookie", async () => { + assert.ok(cookie, "need cookie from previous test") + const r = await makeRequest( + { ...base, path: "/api/admin/logout", method: "POST", headers: { Cookie: cookie } }, + {} + ) + assert.strictEqual(r.status, 200) + assert.strictEqual(r.body?.ok, true) + const setCookie = r.headers["set-cookie"] + assert.ok(setCookie, "expected Set-Cookie header on logout") + const cookieStr = Array.isArray(setCookie) ? setCookie[0] : setCookie + assert.ok( + cookieStr.includes("Max-Age=0") || cookieStr.includes("Expires=Thu, 01 Jan 1970"), + "logout should clear cookie" + ) + }) + + // After logout, GET / → 302 again + await t.test("after logout, GET / returns 302 again", async () => { + assert.ok(cookie, "need cookie from previous test") + const r = await makeRequest({ ...base, path: "/", method: "GET", headers: { Cookie: cookie } }) + assert.strictEqual(r.status, 302) + }) + + // After logout, GET /api/status → 401 again + await t.test("after logout, GET /api/status returns 401", async () => { + assert.ok(cookie, "need cookie from previous test") + const r = await makeRequest({ ...base, path: "/api/status", method: "GET", headers: { Cookie: cookie } }) + assert.strictEqual(r.status, 401) + }) + } finally { + stopServer(proc, tmp) + } +})