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
|
||||
*.tgz
|
||||
*.zip
|
||||
.superpowers/
|
||||
coverage/
|
||||
node_modules/
|
||||
state/
|
||||
|
||||
+10
-1
@@ -2,10 +2,19 @@ HERMES_CONTAINER_USER=0:0
|
||||
|
||||
HERMES_SETUP_UI_PORT=7843
|
||||
HERMES_PRE_AI_API_PORT=8645
|
||||
HERMES_PRE_AI_PROVIDER=nous
|
||||
HERMES_POST_AI_API_PORT=8646
|
||||
|
||||
HERMES_PRE_AI_PROVIDER=nous
|
||||
HERMES_POST_AI_PROVIDER=nous
|
||||
|
||||
HERMES_HOME_HOST=/opt/hermes-control-plane/hermes
|
||||
CODEX_HOME_HOST=/opt/hermes-control-plane/codex
|
||||
CLAUDE_HOME_HOST=/opt/hermes-control-plane/claude
|
||||
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 ./
|
||||
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 lib/ ./lib/
|
||||
COPY migrations/ ./migrations/
|
||||
|
||||
+68
-10
@@ -46,25 +46,30 @@ services:
|
||||
<<: *hermes-environment
|
||||
HERMES_SETUP_UI_HOST: 0.0.0.0
|
||||
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
|
||||
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
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
hermes-pre-ai-api:
|
||||
hermes-pre-upstream:
|
||||
build: *hermes-build
|
||||
image: hermes-control-plane:local
|
||||
user: ${HERMES_CONTAINER_USER:-0:0}
|
||||
restart: unless-stopped
|
||||
expose:
|
||||
- "8645"
|
||||
command:
|
||||
- /bin/sh
|
||||
- -lc
|
||||
- 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:
|
||||
<<: *hermes-environment
|
||||
HERMES_PRE_AI_PROVIDER: ${HERMES_PRE_AI_PROVIDER:-nous}
|
||||
@@ -76,24 +81,77 @@ services:
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
|
||||
hermes-post-ai-api:
|
||||
hermes-post-upstream:
|
||||
build: *hermes-build
|
||||
image: hermes-control-plane:local
|
||||
user: ${HERMES_CONTAINER_USER:-0:0}
|
||||
restart: unless-stopped
|
||||
expose:
|
||||
- "8642"
|
||||
command:
|
||||
- /bin/sh
|
||||
- -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:
|
||||
- "${HERMES_POST_AI_API_PORT:-8646}:8646"
|
||||
environment:
|
||||
<<: *hermes-environment
|
||||
HERMES_POST_AI_PROVIDER: ${HERMES_POST_AI_PROVIDER:-nous}
|
||||
volumes: *hermes-volumes
|
||||
DATABASE_URL: ${DATABASE_URL}
|
||||
HERMES_API_ROUTE_KIND: post
|
||||
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:
|
||||
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
|
||||
timeout: 10s
|
||||
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