feat: route public api traffic through managed gateway
Replace the 3-service compose with a 5-service architecture that puts api-gateway.cjs (hermes-pre-api, hermes-post-api) in front of internal upstream services (hermes-pre-upstream, hermes-post-upstream), ensuring all public API traffic passes through the auth/audit layer. Update Dockerfile, .env.example, .dockerignore, and add a compose contract test. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@
|
|||||||
*.log
|
*.log
|
||||||
*.tgz
|
*.tgz
|
||||||
*.zip
|
*.zip
|
||||||
|
.superpowers/
|
||||||
coverage/
|
coverage/
|
||||||
node_modules/
|
node_modules/
|
||||||
state/
|
state/
|
||||||
|
|||||||
+10
-1
@@ -2,10 +2,19 @@ HERMES_CONTAINER_USER=0:0
|
|||||||
|
|
||||||
HERMES_SETUP_UI_PORT=7843
|
HERMES_SETUP_UI_PORT=7843
|
||||||
HERMES_PRE_AI_API_PORT=8645
|
HERMES_PRE_AI_API_PORT=8645
|
||||||
HERMES_PRE_AI_PROVIDER=nous
|
|
||||||
HERMES_POST_AI_API_PORT=8646
|
HERMES_POST_AI_API_PORT=8646
|
||||||
|
|
||||||
|
HERMES_PRE_AI_PROVIDER=nous
|
||||||
HERMES_POST_AI_PROVIDER=nous
|
HERMES_POST_AI_PROVIDER=nous
|
||||||
|
|
||||||
HERMES_HOME_HOST=/opt/hermes-control-plane/hermes
|
HERMES_HOME_HOST=/opt/hermes-control-plane/hermes
|
||||||
CODEX_HOME_HOST=/opt/hermes-control-plane/codex
|
CODEX_HOME_HOST=/opt/hermes-control-plane/codex
|
||||||
CLAUDE_HOME_HOST=/opt/hermes-control-plane/claude
|
CLAUDE_HOME_HOST=/opt/hermes-control-plane/claude
|
||||||
GEMINI_HOME_HOST=/opt/hermes-control-plane/gemini
|
GEMINI_HOME_HOST=/opt/hermes-control-plane/gemini
|
||||||
|
|
||||||
|
DATABASE_URL=postgresql://hermes_user:change-me@postgres.example.internal:5432/hermes_control_plane
|
||||||
|
HERMES_ADMIN_USERNAME=admin
|
||||||
|
HERMES_ADMIN_PASSWORD=change-this-to-a-long-random-password
|
||||||
|
HERMES_ADMIN_SESSION_TTL_HOURS=12
|
||||||
|
HERMES_LOG_RETENTION_DAYS=90
|
||||||
|
HERMES_AUDIT_MAX_BYTES=10485760
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ WORKDIR /app
|
|||||||
|
|
||||||
COPY package.json package-lock.json ./
|
COPY package.json package-lock.json ./
|
||||||
RUN npm ci --omit=dev
|
RUN npm ci --omit=dev
|
||||||
COPY index.html app.js style.css server.cjs README.md ./
|
COPY index.html app.js style.css server.cjs api-gateway.cjs README.md ./
|
||||||
COPY login.html login.js login.css ./
|
COPY login.html login.js login.css ./
|
||||||
COPY lib/ ./lib/
|
COPY lib/ ./lib/
|
||||||
COPY migrations/ ./migrations/
|
COPY migrations/ ./migrations/
|
||||||
|
|||||||
+68
-10
@@ -46,25 +46,30 @@ services:
|
|||||||
<<: *hermes-environment
|
<<: *hermes-environment
|
||||||
HERMES_SETUP_UI_HOST: 0.0.0.0
|
HERMES_SETUP_UI_HOST: 0.0.0.0
|
||||||
HERMES_SETUP_UI_PORT: 7843
|
HERMES_SETUP_UI_PORT: 7843
|
||||||
|
DATABASE_URL: ${DATABASE_URL}
|
||||||
|
HERMES_ADMIN_USERNAME: ${HERMES_ADMIN_USERNAME}
|
||||||
|
HERMES_ADMIN_PASSWORD: ${HERMES_ADMIN_PASSWORD}
|
||||||
|
HERMES_ADMIN_SESSION_TTL_HOURS: ${HERMES_ADMIN_SESSION_TTL_HOURS:-12}
|
||||||
|
HERMES_LOG_RETENTION_DAYS: ${HERMES_LOG_RETENTION_DAYS:-90}
|
||||||
volumes: *hermes-volumes
|
volumes: *hermes-volumes
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "node -e \"fetch('http://127.0.0.1:7843/api/paths').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\""]
|
test: ["CMD-SHELL", "node -e \"fetch('http://127.0.0.1:7843/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\""]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 10s
|
start_period: 10s
|
||||||
|
|
||||||
hermes-pre-ai-api:
|
hermes-pre-upstream:
|
||||||
build: *hermes-build
|
build: *hermes-build
|
||||||
image: hermes-control-plane:local
|
image: hermes-control-plane:local
|
||||||
user: ${HERMES_CONTAINER_USER:-0:0}
|
user: ${HERMES_CONTAINER_USER:-0:0}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
expose:
|
||||||
|
- "8645"
|
||||||
command:
|
command:
|
||||||
- /bin/sh
|
- /bin/sh
|
||||||
- -lc
|
- -lc
|
||||||
- exec "$$HERMES_EXE" proxy start --provider "$$HERMES_PRE_AI_PROVIDER" --host 0.0.0.0 --port 8645
|
- exec "$$HERMES_EXE" proxy start --provider "$$HERMES_PRE_AI_PROVIDER" --host 0.0.0.0 --port 8645
|
||||||
ports:
|
|
||||||
- "${HERMES_PRE_AI_API_PORT:-8645}:8645"
|
|
||||||
environment:
|
environment:
|
||||||
<<: *hermes-environment
|
<<: *hermes-environment
|
||||||
HERMES_PRE_AI_PROVIDER: ${HERMES_PRE_AI_PROVIDER:-nous}
|
HERMES_PRE_AI_PROVIDER: ${HERMES_PRE_AI_PROVIDER:-nous}
|
||||||
@@ -76,24 +81,77 @@ services:
|
|||||||
retries: 3
|
retries: 3
|
||||||
start_period: 20s
|
start_period: 20s
|
||||||
|
|
||||||
hermes-post-ai-api:
|
hermes-post-upstream:
|
||||||
build: *hermes-build
|
build: *hermes-build
|
||||||
image: hermes-control-plane:local
|
image: hermes-control-plane:local
|
||||||
user: ${HERMES_CONTAINER_USER:-0:0}
|
user: ${HERMES_CONTAINER_USER:-0:0}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
expose:
|
||||||
|
- "8642"
|
||||||
command:
|
command:
|
||||||
- /bin/sh
|
- /bin/sh
|
||||||
- -lc
|
- -lc
|
||||||
- exec "$$HERMES_EXE" proxy start --provider "$$HERMES_POST_AI_PROVIDER" --host 0.0.0.0 --port 8646
|
- exec "$$HERMES_EXE" gateway run --replace --accept-hooks
|
||||||
|
environment:
|
||||||
|
<<: *hermes-environment
|
||||||
|
API_SERVER_ENABLED: "true"
|
||||||
|
API_SERVER_HOST: 0.0.0.0
|
||||||
|
API_SERVER_PORT: 8642
|
||||||
|
HERMES_POST_AI_PROVIDER: ${HERMES_POST_AI_PROVIDER:-nous}
|
||||||
|
volumes: *hermes-volumes
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "node -e \"fetch('http://127.0.0.1:8642/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\""]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 20s
|
||||||
|
|
||||||
|
hermes-pre-api:
|
||||||
|
build: *hermes-build
|
||||||
|
image: hermes-control-plane:local
|
||||||
|
user: ${HERMES_CONTAINER_USER:-0:0}
|
||||||
|
restart: unless-stopped
|
||||||
|
command: ["node", "/app/api-gateway.cjs"]
|
||||||
|
ports:
|
||||||
|
- "${HERMES_PRE_AI_API_PORT:-8645}:8645"
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: ${DATABASE_URL}
|
||||||
|
HERMES_API_ROUTE_KIND: pre
|
||||||
|
HERMES_API_GATEWAY_HOST: 0.0.0.0
|
||||||
|
HERMES_API_GATEWAY_PORT: 8645
|
||||||
|
HERMES_UPSTREAM_URL: http://hermes-pre-upstream:8645
|
||||||
|
HERMES_LOG_RETENTION_DAYS: ${HERMES_LOG_RETENTION_DAYS:-90}
|
||||||
|
HERMES_AUDIT_MAX_BYTES: ${HERMES_AUDIT_MAX_BYTES:-10485760}
|
||||||
|
depends_on:
|
||||||
|
- hermes-pre-upstream
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "node -e \"fetch('http://127.0.0.1:8645/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\""]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
hermes-post-api:
|
||||||
|
build: *hermes-build
|
||||||
|
image: hermes-control-plane:local
|
||||||
|
user: ${HERMES_CONTAINER_USER:-0:0}
|
||||||
|
restart: unless-stopped
|
||||||
|
command: ["node", "/app/api-gateway.cjs"]
|
||||||
ports:
|
ports:
|
||||||
- "${HERMES_POST_AI_API_PORT:-8646}:8646"
|
- "${HERMES_POST_AI_API_PORT:-8646}:8646"
|
||||||
environment:
|
environment:
|
||||||
<<: *hermes-environment
|
DATABASE_URL: ${DATABASE_URL}
|
||||||
HERMES_POST_AI_PROVIDER: ${HERMES_POST_AI_PROVIDER:-nous}
|
HERMES_API_ROUTE_KIND: post
|
||||||
volumes: *hermes-volumes
|
HERMES_API_GATEWAY_HOST: 0.0.0.0
|
||||||
|
HERMES_API_GATEWAY_PORT: 8646
|
||||||
|
HERMES_UPSTREAM_URL: http://hermes-post-upstream:8642
|
||||||
|
HERMES_LOG_RETENTION_DAYS: ${HERMES_LOG_RETENTION_DAYS:-90}
|
||||||
|
HERMES_AUDIT_MAX_BYTES: ${HERMES_AUDIT_MAX_BYTES:-10485760}
|
||||||
|
depends_on:
|
||||||
|
- hermes-post-upstream
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "node -e \"fetch('http://127.0.0.1:8646/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\""]
|
test: ["CMD-SHELL", "node -e \"fetch('http://127.0.0.1:8646/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\""]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 20s
|
start_period: 10s
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
"use strict"
|
||||||
|
|
||||||
|
const test = require("node:test")
|
||||||
|
const assert = require("node:assert/strict")
|
||||||
|
const { execSync } = require("child_process")
|
||||||
|
const path = require("path")
|
||||||
|
|
||||||
|
const root = path.join(__dirname, "..")
|
||||||
|
|
||||||
|
function getCompose() {
|
||||||
|
try {
|
||||||
|
const output = execSync(
|
||||||
|
"docker compose --env-file .env.example config --format json",
|
||||||
|
{ cwd: root, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }
|
||||||
|
)
|
||||||
|
return JSON.parse(output)
|
||||||
|
} catch (err) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test("compose config is valid and has correct service structure", (t) => {
|
||||||
|
const config = getCompose()
|
||||||
|
if (!config) {
|
||||||
|
t.skip("docker compose not available or config invalid")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const services = config.services || {}
|
||||||
|
const serviceNames = Object.keys(services)
|
||||||
|
|
||||||
|
// Must have exactly these 5 services
|
||||||
|
assert(serviceNames.includes("hermes-control-plane"), "missing hermes-control-plane")
|
||||||
|
assert(serviceNames.includes("hermes-pre-upstream"), "missing hermes-pre-upstream")
|
||||||
|
assert(serviceNames.includes("hermes-post-upstream"), "missing hermes-post-upstream")
|
||||||
|
assert(serviceNames.includes("hermes-pre-api"), "missing hermes-pre-api")
|
||||||
|
assert(serviceNames.includes("hermes-post-api"), "missing hermes-post-api")
|
||||||
|
|
||||||
|
// Upstream services must NOT have host ports
|
||||||
|
const preUpstream = services["hermes-pre-upstream"]
|
||||||
|
const postUpstream = services["hermes-post-upstream"]
|
||||||
|
const preApi = services["hermes-pre-api"]
|
||||||
|
const postApi = services["hermes-post-api"]
|
||||||
|
|
||||||
|
assert(!preUpstream.ports || preUpstream.ports.length === 0, "hermes-pre-upstream should not publish host ports")
|
||||||
|
assert(!postUpstream.ports || postUpstream.ports.length === 0, "hermes-post-upstream should not publish host ports")
|
||||||
|
|
||||||
|
// Public gateway services must publish correct ports
|
||||||
|
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")
|
||||||
|
|
||||||
|
// 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")
|
||||||
|
|
||||||
|
// Control plane and gateway services must receive DATABASE_URL
|
||||||
|
const controlPlaneEnv = services["hermes-control-plane"].environment || {}
|
||||||
|
assert("DATABASE_URL" in controlPlaneEnv, "hermes-control-plane must have DATABASE_URL")
|
||||||
|
|
||||||
|
const preApiEnv = preApi.environment || {}
|
||||||
|
assert("DATABASE_URL" in preApiEnv, "hermes-pre-api must have DATABASE_URL")
|
||||||
|
|
||||||
|
const postApiEnv = postApi.environment || {}
|
||||||
|
assert("DATABASE_URL" in postApiEnv, "hermes-post-api must have DATABASE_URL")
|
||||||
|
|
||||||
|
// Admin variables only go to control plane
|
||||||
|
assert("HERMES_ADMIN_USERNAME" in controlPlaneEnv, "hermes-control-plane must have HERMES_ADMIN_USERNAME")
|
||||||
|
assert(!("HERMES_ADMIN_USERNAME" in preApiEnv), "hermes-pre-api must NOT have HERMES_ADMIN_USERNAME")
|
||||||
|
assert(!("HERMES_ADMIN_USERNAME" in postApiEnv), "hermes-post-api must NOT have HERMES_ADMIN_USERNAME")
|
||||||
|
|
||||||
|
// Gateway services run api-gateway.cjs
|
||||||
|
const preApiCommand = preApi.command
|
||||||
|
const postApiCommand = postApi.command
|
||||||
|
const preApiCmd = Array.isArray(preApiCommand) ? preApiCommand.join(" ") : String(preApiCommand || "")
|
||||||
|
const postApiCmd = Array.isArray(postApiCommand) ? postApiCommand.join(" ") : String(postApiCommand || "")
|
||||||
|
assert.match(preApiCmd, /api-gateway\.cjs/, "hermes-pre-api must run api-gateway.cjs")
|
||||||
|
assert.match(postApiCmd, /api-gateway\.cjs/, "hermes-post-api must run api-gateway.cjs")
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user