fix: bind published ports to loopback and fix runHermes hang

- Ports now bind to HERMES_PUBLISHED_BIND_IP (default 127.0.0.1) so
  NPM on the same host proxies to 127.0.0.1:7843/8645/8646 and direct
  LAN/internet access is blocked without firewall rules
- runHermes: settle promise immediately on timeout (SIGKILL) instead of
  waiting for close event — prevents hanging when hermes spawns children
  that keep stdout/stderr open after the parent is killed
- Add HERMES_ADMIN_COOKIE_SECURE env var to set Secure flag on admin
  session cookie when the admin UI is served over HTTPS
- Document NPM deployment shapes in README

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-07 21:01:10 -06:00
parent 8920e746f4
commit 74e276af92
7 changed files with 72 additions and 15 deletions
+2
View File
@@ -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
+16 -1
View File
@@ -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=<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://<docker-host>: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:
+4 -3
View File
@@ -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
+6 -4
View File
@@ -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 = {
+14 -6
View File
@@ -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) {
+9
View File
@@ -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")
+21 -1
View File
@@ -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/)
})