diff --git a/.env.example b/.env.example index c52f9b3..ef44634 100644 --- a/.env.example +++ b/.env.example @@ -3,6 +3,7 @@ HERMES_CONTAINER_USER=0:0 HERMES_SETUP_UI_PORT=7843 HERMES_PRE_AI_API_PORT=8645 HERMES_POST_AI_API_PORT=8646 +HERMES_PUBLISHED_BIND_IP=127.0.0.1 HERMES_PRE_AI_PROVIDER=nous HERMES_POST_AI_PROVIDER=nous @@ -16,5 +17,6 @@ POSTGRES_PASSWORD=hermes-change-me HERMES_ADMIN_USERNAME=admin HERMES_ADMIN_PASSWORD=change-this-to-a-long-random-password HERMES_ADMIN_SESSION_TTL_HOURS=12 +HERMES_ADMIN_COOKIE_SECURE=false HERMES_LOG_RETENTION_DAYS=90 HERMES_AUDIT_MAX_BYTES=10485760 diff --git a/README.md b/README.md index eccacb3..ae152e3 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ POSTGRES_PASSWORD PostgreSQL password (default: hermes-change-me HERMES_ADMIN_USERNAME Admin username (default: admin) HERMES_ADMIN_PASSWORD Admin password — minimum 16 characters HERMES_ADMIN_SESSION_TTL_HOURS Session lifetime in hours (default: 8) +HERMES_ADMIN_COOKIE_SECURE Set true when admin UI is served only through HTTPS HERMES_LOG_RETENTION_DAYS Audit log retention in days (default: 90) HERMES_AUDIT_MAX_BYTES Max bytes per logged request body (default: 65536) ``` @@ -54,6 +55,8 @@ The admin password must be at least 16 characters. Sessions expire after `HERMES On first run, set your admin credentials in the environment before starting the container. There is no in-app registration flow. +If the admin UI is exposed through an HTTPS reverse proxy, set `HERMES_ADMIN_COOKIE_SECURE=true` so browsers do not send the admin session cookie over plain HTTP. + ## API Access (Pre/Post) API keys start with `hms_`. Each key is scoped to allow the pre gateway, the post gateway, or both. @@ -90,7 +93,7 @@ Download logs as JSONL from the admin UI or directly: ```http GET /api/admin/logs/download?api_user_id= -Cookie: hermes_session=... +Cookie: hermes_admin=... ``` Response content-type is `application/x-ndjson`. Each line is a JSON object with request metadata, prompt, and response. @@ -115,6 +118,18 @@ Use Portainer's Git-backed Stack flow: Portainer pulls the latest Git version when it redeploys the Stack. A normal Docker container restart does not pull Git or rebuild the image, so use Portainer's webhook/polling redeploy for updates. +### Nginx Proxy Manager + +The compose stack binds published ports to `127.0.0.1` by default via `HERMES_PUBLISHED_BIND_IP`. This prevents clients on the LAN or internet from bypassing Nginx Proxy Manager by calling `http://:7843`, `:8645`, or `:8646` directly. + +Use one of these deployment shapes: + +- **NPM on the same Docker host:** keep `HERMES_PUBLISHED_BIND_IP=127.0.0.1` and proxy to `127.0.0.1:7843`, `127.0.0.1:8645`, or `127.0.0.1:8646`. +- **NPM in a container on a shared Docker network:** prefer adding NPM to the stack network and proxy to service names such as `hermes-control-plane:7843`, without broad host-port exposure. +- **NPM on another host/LXC:** set `HERMES_PUBLISHED_BIND_IP` to the private interface IP that only NPM can reach, then block direct client access to ports `7843`, `8645`, and `8646` with host or network firewall rules. + +When the public admin URL is HTTPS, also set `HERMES_ADMIN_COOKIE_SECURE=true`. + `HERMES_CONTAINER_USER` defaults to `0:0` so the container can write credentials/config into Portainer-created bind directories. If you pre-create the host directories and `chown` them to UID/GID `1000:1000`, set `HERMES_CONTAINER_USER=1000:1000`. Recommended persistent host layout on the Portainer host: diff --git a/docker-compose.yml b/docker-compose.yml index 6671ea2..015d76e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -59,7 +59,7 @@ services: user: ${HERMES_CONTAINER_USER:-0:0} restart: unless-stopped ports: - - "${HERMES_SETUP_UI_PORT:-7843}:7843" + - "${HERMES_PUBLISHED_BIND_IP:-127.0.0.1}:${HERMES_SETUP_UI_PORT:-7843}:7843" environment: <<: *hermes-environment HERMES_SETUP_UI_HOST: 0.0.0.0 @@ -68,6 +68,7 @@ services: HERMES_ADMIN_USERNAME: ${HERMES_ADMIN_USERNAME:-admin} HERMES_ADMIN_PASSWORD: ${HERMES_ADMIN_PASSWORD} HERMES_ADMIN_SESSION_TTL_HOURS: ${HERMES_ADMIN_SESSION_TTL_HOURS:-12} + HERMES_ADMIN_COOKIE_SECURE: ${HERMES_ADMIN_COOKIE_SECURE:-false} HERMES_LOG_RETENTION_DAYS: ${HERMES_LOG_RETENTION_DAYS:-90} volumes: *hermes-volumes depends_on: @@ -134,7 +135,7 @@ services: restart: unless-stopped command: ["node", "/app/api-gateway.cjs"] ports: - - "${HERMES_PRE_AI_API_PORT:-8645}:8645" + - "${HERMES_PUBLISHED_BIND_IP:-127.0.0.1}:${HERMES_PRE_AI_API_PORT:-8645}:8645" environment: DATABASE_URL: *db-url HERMES_API_ROUTE_KIND: pre @@ -162,7 +163,7 @@ services: restart: unless-stopped command: ["node", "/app/api-gateway.cjs"] ports: - - "${HERMES_POST_AI_API_PORT:-8646}:8646" + - "${HERMES_PUBLISHED_BIND_IP:-127.0.0.1}:${HERMES_POST_AI_API_PORT:-8646}:8646" environment: DATABASE_URL: *db-url HERMES_API_ROUTE_KIND: post diff --git a/lib/security.cjs b/lib/security.cjs index 3c6f315..c2877d8 100644 --- a/lib/security.cjs +++ b/lib/security.cjs @@ -100,16 +100,18 @@ function parseCookies(cookieHeader) { * @param {number} ttlSeconds * @returns {string} */ -function serializeAdminCookie(plaintext, ttlSeconds) { - return `hermes_admin=${plaintext}; HttpOnly; Path=/; SameSite=Lax; Max-Age=${ttlSeconds}` +function serializeAdminCookie(plaintext, ttlSeconds, options = {}) { + const secure = options.secure ? "; Secure" : "" + return `hermes_admin=${plaintext}; HttpOnly; Path=/; SameSite=Lax; Max-Age=${ttlSeconds}${secure}` } /** * Return a Set-Cookie header value that expires/clears the admin cookie. * @returns {string} */ -function clearAdminCookie() { - return "hermes_admin=; HttpOnly; Path=/; SameSite=Lax; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT" +function clearAdminCookie(options = {}) { + const secure = options.secure ? "; Secure" : "" + return `hermes_admin=; HttpOnly; Path=/; SameSite=Lax; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT${secure}` } module.exports = { diff --git a/server.cjs b/server.cjs index 28a8ef1..2118767 100644 --- a/server.cjs +++ b/server.cjs @@ -54,6 +54,7 @@ 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 +const ADMIN_COOKIE_SECURE = /^(1|true|yes|on)$/i.test(String(process.env.HERMES_ADMIN_COOKIE_SECURE || "")) // Module-level pool — null when DATABASE_URL is not set (auth disabled) let pool = null @@ -61,7 +62,7 @@ let pool = null // ─── Process tracking ───────────────────────────────────────────────────── const runningProcs = new Map() // key: provider, val: state -function runHermes(args, timeoutMs = 5000) { +function runHermes(args, timeoutMs = 30000) { return new Promise((resolve) => { const proc = spawn(HERMES_EXE, args, { env: { ...process.env, NO_COLOR: "1", HERMES_NO_TUI: "1", PYTHONIOENCODING: "utf-8" }, @@ -69,13 +70,20 @@ function runHermes(args, timeoutMs = 5000) { }) let stdout = "" let stderr = "" + let settled = false + function settle(result) { + if (settled) return + settled = true + resolve(result) + } const timer = setTimeout(() => { - try { proc.kill() } catch {} + try { proc.kill("SIGKILL") } catch {} + settle({ code: -1, stdout, stderr: stderr + "\ntimeout" }) }, timeoutMs) proc.stdout.on("data", (d) => { stdout += d.toString("utf-8") }) proc.stderr.on("data", (d) => { stderr += d.toString("utf-8") }) - proc.on("close", (code) => { clearTimeout(timer); resolve({ code, stdout, stderr }) }) - proc.on("error", (err) => { clearTimeout(timer); resolve({ code: -1, stdout, stderr: stderr + "\n" + err.message }) }) + proc.on("close", (code) => { clearTimeout(timer); settle({ code, stdout, stderr }) }) + proc.on("error", (err) => { clearTimeout(timer); settle({ code: -1, stdout, stderr: stderr + "\n" + err.message }) }) }) } @@ -1398,7 +1406,7 @@ async function h_adminLogin(req, res) { await createAdminSession(pool, hash, expiresAt) sendJson(res, 200, { ok: true }, { - "Set-Cookie": serializeAdminCookie(plaintext, SESSION_TTL_SECONDS) + "Set-Cookie": serializeAdminCookie(plaintext, SESSION_TTL_SECONDS, { secure: ADMIN_COOKIE_SECURE }) }) } @@ -1411,7 +1419,7 @@ async function h_adminLogout(req, res) { await revokeAdminSession(pool, hash).catch(() => {}) } } - sendJson(res, 200, { ok: true }, { "Set-Cookie": clearAdminCookie() }) + sendJson(res, 200, { ok: true }, { "Set-Cookie": clearAdminCookie({ secure: ADMIN_COOKIE_SECURE }) }) } function h_health(_req, res) { diff --git a/test/compose-contract.test.cjs b/test/compose-contract.test.cjs index fff55ff..8412e93 100644 --- a/test/compose-contract.test.cjs +++ b/test/compose-contract.test.cjs @@ -50,6 +50,15 @@ test("compose config is valid and has correct service structure", (t) => { assert(preApi.ports && preApi.ports.length > 0, "hermes-pre-api should publish ports") assert(postApi.ports && postApi.ports.length > 0, "hermes-post-api should publish ports") + const controlPlane = services["hermes-control-plane"] + const controlPlanePort = controlPlane.ports?.find((port) => Number(port.target) === 7843) + const preApiPort = preApi.ports?.find((port) => Number(port.target) === 8645) + const postApiPort = postApi.ports?.find((port) => Number(port.target) === 8646) + + assert.equal(controlPlanePort?.host_ip, "127.0.0.1", "control plane should bind to loopback by default") + assert.equal(preApiPort?.host_ip, "127.0.0.1", "pre API should bind to loopback by default") + assert.equal(postApiPort?.host_ip, "127.0.0.1", "post API should bind to loopback by default") + // Post upstream must have API_SERVER_ENABLED=true const postUpstreamEnv = postUpstream.environment || {} assert.equal(String(postUpstreamEnv.API_SERVER_ENABLED), "true", "hermes-post-upstream API_SERVER_ENABLED must be true") diff --git a/test/security.test.cjs b/test/security.test.cjs index d3e0917..1186c69 100644 --- a/test/security.test.cjs +++ b/test/security.test.cjs @@ -2,7 +2,15 @@ const test = require("node:test") const assert = require("node:assert/strict") -const { adminCredentialsMatch, createSessionToken, createApiKey, hashSecret, parseCookies } = require("../lib/security.cjs") +const { + adminCredentialsMatch, + createSessionToken, + createApiKey, + hashSecret, + parseCookies, + serializeAdminCookie, + clearAdminCookie, +} = require("../lib/security.cjs") test("adminCredentialsMatch returns true for correct credentials", (t) => { assert.equal(adminCredentialsMatch("admin", "secret-value-123", { @@ -50,3 +58,15 @@ test("parseCookies returns empty object for null/undefined", (t) => { assert.deepEqual(parseCookies(undefined), {}) assert.deepEqual(parseCookies(""), {}) }) + +test("serializeAdminCookie can mark admin session cookies Secure for HTTPS proxy deployments", (t) => { + const cookie = serializeAdminCookie("session-value", 3600, { secure: true }) + assert.match(cookie, /; HttpOnly\b/) + assert.match(cookie, /; SameSite=Lax\b/) + assert.match(cookie, /; Secure\b/) +}) + +test("clearAdminCookie can mark cleared admin cookies Secure for HTTPS proxy deployments", (t) => { + const cookie = clearAdminCookie({ secure: true }) + assert.match(cookie, /; Secure\b/) +})