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
+
+
+
+
+
Hermes
+
Sign in to the control plane
+
+
+
+
+
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)
+ }
+})