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 <noreply@anthropic.com>
This commit is contained in:
2026-06-06 01:41:09 -06:00
parent ac865ba725
commit 76e9774c65
7 changed files with 606 additions and 6 deletions
+5
View File
@@ -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 \
+42
View File
@@ -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 }
+99
View File
@@ -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;
}
+41
View File
@@ -0,0 +1,41 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Hermes — Sign In</title>
<link rel="stylesheet" href="/login.css" />
</head>
<body>
<div class="login-container">
<h1 class="login-title">Hermes</h1>
<p class="login-subtitle">Sign in to the control plane</p>
<form id="login-form" class="login-form" novalidate>
<div class="form-group">
<label for="username">Username</label>
<input
type="text"
id="username"
name="username"
autocomplete="username"
required
autofocus
/>
</div>
<div class="form-group">
<label for="password">Password</label>
<input
type="password"
id="password"
name="password"
autocomplete="current-password"
required
/>
</div>
<p id="login-error" class="login-error" role="alert" aria-live="polite"></p>
<button type="submit" class="login-btn">Sign In</button>
</form>
</div>
<script src="/login.js"></script>
</body>
</html>
+25
View File
@@ -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"
}
})
+141 -6
View File
@@ -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)
})
+253
View File
@@ -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)
}
})