feat(gateway): add environment-aware HTTP and websocket routing
New internal component (platform/gateway/) — the only student-facing path to
disposable Lab environments. Browsers never reach a Lab container, the
Provisioner, or Postgres directly.
Routes:
- /api/environment/{status,launch,reset} — student-facing glue (X-Student-Id),
calls the Provisioner internally (shared secret never reaches the browser).
Reuses an existing non-terminal environment on launch rather than always
provisioning a new one; reset destroys+recreates the same environment row.
- /lab/<token>/* — HTTP+WS proxy into that one student's Lab container,
resolved by opaque routing token via a new Provisioner endpoint
(GET /environments/by-token/:token). No raw container IP/port ever reaches
the browser. Rejects invalid tokens (404), unready environments (202/503),
and cross-student access via an optional ?sid= ownership check (403) — see
docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md "Security" for why token-alone,
student-id-alone, and env-id-alone are each individually insufficient and
this is explicitly pilot-grade, not production auth.
- everything else — proxied to the persistent identity/curriculum backend
(roster, login, curriculum, progress, platform-shell frontend), or to the
student's own Lab if a valid lab-session cookie is present.
Routing design note: the existing Lab UI makes root-relative calls
(fetch('/api/scenarios'), the /shell-ws WebSocket) with no idea it might be
served under a /lab/<token>/ prefix — preserving that UI unmodified was a
hard requirement. Solved with an HttpOnly, SameSite=Lax cookie set on the
first /lab/<token>/ response: every later request from that browser carries
it, so the unmodified Lab UI's own unprefixed calls resolve correctly once a
student has navigated into a lab session, with zero changes to its code.
/api/environment/* is explicitly exempted from cookie-based routing so it
never gets hijacked by an active lab session.
Also added platform/docker-compose.dev.yml (the full local stack: Postgres +
egress-proxy + Provisioner + a persistent "identity" instance + Gateway) and
platform/README.md tying the components together. The "identity" instance
required an explicit `network_mode: bridge` — under Compose's own
auto-created bridge network, k3s failed at startup with "no default routes
found in /proc/net/route"; documented as a known limitation, not fully
root-caused.
Found and fixed during testing: express.json() applied globally broke
http-proxy's re-streaming of proxied POST bodies (e.g. POST /api/roster hung
forever, since the body had already been consumed by the parser before
proxy.web() tried to forward it). Removed — none of the Gateway's own routes
actually need a parsed body.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
# Phase 2A full-stack local dev/test — Postgres + egress-proxy + Provisioner + a persistent
|
||||
# "identity" instance + the Gateway, all on one machine (the documented Phase 1/2A dev
|
||||
# exception; production is the two-VM Proxmox split, see docs/LAB_ARCHITECTURE.md §1).
|
||||
#
|
||||
# "identity" is a plain, non-ephemeral instance of the existing, unmodified rootnode-academy
|
||||
# image — it's what holds the canonical roster/curriculum/progress SQLite and serves the
|
||||
# platform-shell frontend. It is NOT managed by the Provisioner and never gets destroyed/reset;
|
||||
# it's infrastructure, the same way Postgres or the egress proxy are. See
|
||||
# docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md for why this role exists and its Phase 2B
|
||||
# optimization opportunity (it currently boots a full unused k3s cluster just to serve as the
|
||||
# identity backend — wasteful but zero-new-code, matching "don't redesign the Lab image").
|
||||
#
|
||||
# gateway and provisioner both run with network_mode: host — both need to reach ephemeral
|
||||
# per-student Lab containers by their internal Docker-network IP, the same requirement that
|
||||
# already applies to the Provisioner's own firewall management (see
|
||||
# platform/provisioner/docker-compose.dev.yml).
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: rootnode_platform
|
||||
ports:
|
||||
- "5433:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
|
||||
egress-proxy:
|
||||
build: ./egress-proxy
|
||||
network_mode: host
|
||||
|
||||
provisioner:
|
||||
build: ./provisioner
|
||||
network_mode: host
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
- NET_RAW
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
environment:
|
||||
PLATFORM_DB_URL: postgres://postgres:postgres@localhost:5433/rootnode_platform
|
||||
PROVISIONER_SHARED_SECRET: ${PROVISIONER_SHARED_SECRET:?set a real secret, do not use a default}
|
||||
PROVISIONER_PORT: "4100"
|
||||
LAB_IMAGE_REF: ${LAB_IMAGE_REF:-rootnode-academy:test}
|
||||
EGRESS_PROXY_PORT: "3128"
|
||||
DNS_RESOLVER: "127.0.0.11"
|
||||
NO_PROXY_CIDRS: "10.42.0.0/16,10.43.0.0/16"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
||||
identity:
|
||||
image: ${LAB_IMAGE_REF:-rootnode-academy:test}
|
||||
privileged: true
|
||||
network_mode: bridge
|
||||
ports:
|
||||
- "7554:80"
|
||||
volumes:
|
||||
- identity-data:/data
|
||||
|
||||
gateway:
|
||||
build: ./gateway
|
||||
network_mode: host
|
||||
environment:
|
||||
GATEWAY_PORT: "4200"
|
||||
PROVISIONER_URL: "http://localhost:4100"
|
||||
PROVISIONER_SHARED_SECRET: ${PROVISIONER_SHARED_SECRET:?set a real secret, do not use a default}
|
||||
IDENTITY_BACKEND_URL: "http://localhost:7554"
|
||||
depends_on:
|
||||
- provisioner
|
||||
- identity
|
||||
|
||||
volumes:
|
||||
identity-data:
|
||||
Reference in New Issue
Block a user