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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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/)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user