From c89e39c5ee4545aa5ca93433973cdde4be170a47 Mon Sep 17 00:00:00 2001 From: ced4568 Date: Tue, 18 Aug 2026 11:15:55 -0500 Subject: [PATCH] feat(gateway): add environment-aware HTTP and websocket routing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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//* — 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// prefix — preserving that UI unmodified was a hard requirement. Solved with an HttpOnly, SameSite=Lax cookie set on the first /lab// 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 --- platform/README.md | 47 ++++ platform/docker-compose.dev.yml | 79 ++++++ platform/gateway/Dockerfile | 7 + platform/gateway/package.json | 14 + platform/gateway/src/env-cache.js | 23 ++ platform/gateway/src/provisioner-client.js | 36 +++ platform/gateway/src/server.js | 287 +++++++++++++++++++++ platform/provisioner/src/server.js | 23 ++ 8 files changed, 516 insertions(+) create mode 100644 platform/README.md create mode 100644 platform/docker-compose.dev.yml create mode 100644 platform/gateway/Dockerfile create mode 100644 platform/gateway/package.json create mode 100644 platform/gateway/src/env-cache.js create mode 100644 platform/gateway/src/provisioner-client.js create mode 100644 platform/gateway/src/server.js diff --git a/platform/README.md b/platform/README.md new file mode 100644 index 0000000..86e4e3a --- /dev/null +++ b/platform/README.md @@ -0,0 +1,47 @@ +# RootNode Academy — Platform + +The full multi-student platform, layered on top of the unmodified Lab image at the repo root. +Start here, then go to the component you need: + +- `provisioner/` — Postgres-backed environment registry and lifecycle. Design: + `docs/LAB_ARCHITECTURE.md`, `docs/PHASE1_PLATFORM_FOUNDATION.md`. Operational reference: + `provisioner/README.md`. +- `gateway/` — the only student-facing path to a Lab (HTTP + WebSocket proxy, environment-aware + routing, student identity glue). Design: `docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md`. +- `egress-proxy/` — shared allowlisting proxy every Lab's outbound traffic passes through. +- `docker-compose.dev.yml` — the full stack (Postgres, egress proxy, Provisioner, a persistent + "identity" instance, Gateway) on one machine. This is the Phase 1/2A **local dev exception** + — production is the two-VM Proxmox split described in `docs/LAB_ARCHITECTURE.md` §1. + +## Running the full stack locally + +```bash +cd platform +echo "PROVISIONER_SHARED_SECRET=$(openssl rand -hex 32)" > .env +docker compose -f docker-compose.dev.yml build +docker compose -f docker-compose.dev.yml up -d +``` + +Wait for `identity` to boot (it runs a real k3s cluster, same as any Lab — check with +`curl http://localhost:7554/api/health`), then the whole platform is reachable at +`http://localhost:4200` (the Gateway) — that's the one origin a student's browser would use. + +## Tests + +- `provisioner/test/run.js` — fast, isolated Provisioner tests (network/container/firewall + mechanics, no Gateway/identity involved). Run against `provisioner/docker-compose.dev.yml`. +- `test/e2e-phase2a.js` — slow, full-stack tests against the compose file above: roster → + login → curriculum → launch → Gateway routing → cross-student rejection → progress relay → + reset. Real k3s boots, budget several minutes. Run whenever the Gateway, Provisioner API + surface, or the identity backend's progress/roster/curriculum routes change. + +Neither substitutes for the manual real-Lab-image checks (node-pty, terminal WebSocket, +`kubectl`, scenario validation) in `docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md` §8 — those need a +browser-equivalent client and aren't automated here. + +## Known limitations + +See `provisioner/README.md` (Phase 1) and `docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md` §9 +(Phase 2A) for the full, honest lists — including the `identity` service's `network_mode: +bridge` requirement and its unused-k3s resource cost, both flagged as real, not swept under +the rug. diff --git a/platform/docker-compose.dev.yml b/platform/docker-compose.dev.yml new file mode 100644 index 0000000..27cc81e --- /dev/null +++ b/platform/docker-compose.dev.yml @@ -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: diff --git a/platform/gateway/Dockerfile b/platform/gateway/Dockerfile new file mode 100644 index 0000000..cf2b5b9 --- /dev/null +++ b/platform/gateway/Dockerfile @@ -0,0 +1,7 @@ +FROM node:20-slim +WORKDIR /app +COPY package.json ./ +RUN npm install --production +COPY src/ ./src/ +EXPOSE 4200 +CMD ["node", "src/server.js"] diff --git a/platform/gateway/package.json b/platform/gateway/package.json new file mode 100644 index 0000000..2709279 --- /dev/null +++ b/platform/gateway/package.json @@ -0,0 +1,14 @@ +{ + "name": "rootnode-gateway", + "version": "0.1.0", + "description": "RootNode Academy Phase 2A Gateway — the only student-facing path to disposable Lab environments.", + "private": true, + "main": "src/server.js", + "scripts": { + "start": "node src/server.js" + }, + "dependencies": { + "express": "^4.19.2", + "http-proxy": "^1.18.1" + } +} diff --git a/platform/gateway/src/env-cache.js b/platform/gateway/src/env-cache.js new file mode 100644 index 0000000..6f7ddcc --- /dev/null +++ b/platform/gateway/src/env-cache.js @@ -0,0 +1,23 @@ +// Short-TTL cache for token -> environment lookups, so a page full of static assets doesn't +// hit the Provisioner once per asset. Deliberately short (a few seconds) — correctness +// (routing to a destroyed/reset environment) matters more than shaving a few Provisioner +// round-trips, and a short TTL means a destroy/reset is reflected almost immediately. + +const TTL_MS = 5000; +const cache = new Map(); // token -> { env, expiresAt } + +function get(token) { + const hit = cache.get(token); + if (!hit || hit.expiresAt < Date.now()) return null; + return hit.env; +} + +function set(token, env) { + cache.set(token, { env, expiresAt: Date.now() + TTL_MS }); +} + +function invalidate(token) { + cache.delete(token); +} + +module.exports = { get, set, invalidate }; diff --git a/platform/gateway/src/provisioner-client.js b/platform/gateway/src/provisioner-client.js new file mode 100644 index 0000000..f7bb327 --- /dev/null +++ b/platform/gateway/src/provisioner-client.js @@ -0,0 +1,36 @@ +// Thin client for the Provisioner's internal API. This is the ONLY place the Provisioner +// shared secret is used — it's read from an env var at startup and never forwarded to, or +// readable by, anything the browser sends or receives. See docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md +// "Security requirements" for why this boundary matters. + +const PROVISIONER_URL = process.env.PROVISIONER_URL || 'http://localhost:4100'; +const PROVISIONER_SHARED_SECRET = process.env.PROVISIONER_SHARED_SECRET; + +if (!PROVISIONER_SHARED_SECRET) { + console.error('FATAL: PROVISIONER_SHARED_SECRET is not set. Refusing to start — the Gateway cannot call the Provisioner without it, and must not fall back to calling it unauthenticated.'); + process.exit(1); +} + +async function call(method, path, body) { + const res = await fetch(`${PROVISIONER_URL}${path}`, { + method, + headers: { 'Content-Type': 'application/json', 'X-Provisioner-Key': PROVISIONER_SHARED_SECRET }, + body: body ? JSON.stringify(body) : undefined, + }); + const text = await res.text(); + let json; + try { json = JSON.parse(text); } catch { json = null; } + return { status: res.status, body: json }; +} + +const provisioner = { + getEnvironmentByToken: (token) => call('GET', `/environments/by-token/${encodeURIComponent(token)}`), + getEnvironment: (id) => call('GET', `/environments/${encodeURIComponent(id)}`), + getToken: (id) => call('GET', `/environments/${encodeURIComponent(id)}/token`), + listForStudent: (studentId) => call('GET', `/environments?student_id=${encodeURIComponent(studentId)}`), + provision: (studentId) => call('POST', '/environments', { student_id: studentId }), + reset: (id) => call('POST', `/environments/${encodeURIComponent(id)}/reset`), + destroy: (id) => call('DELETE', `/environments/${encodeURIComponent(id)}`), +}; + +module.exports = { provisioner }; diff --git a/platform/gateway/src/server.js b/platform/gateway/src/server.js new file mode 100644 index 0000000..41b866b --- /dev/null +++ b/platform/gateway/src/server.js @@ -0,0 +1,287 @@ +// RootNode Academy — Phase 2A Gateway. +// +// The only student-facing path to disposable Lab environments (docs/LAB_ARCHITECTURE.md §1/§6, +// docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md). Owns: +// - /api/environment/* — student-facing environment lifecycle glue, calls the Provisioner +// internally (shared secret never reaches the browser). +// - /lab//* — HTTP+WS proxy into a specific student's Lab container, resolved by +// opaque routing token, never a raw container IP/port. +// - everything else — proxied to the persistent identity/curriculum backend (roster, +// login, curriculum path, progress, and the platform-shell frontend), OR to the student's +// own Lab if a valid lab-session cookie is present (see "Routing design" in the doc above +// for why the cookie exists: the existing, unmodified Lab UI makes root-relative /api/* +// and /shell-ws calls that must resolve correctly once a browser has navigated into +// /lab//, without requiring any change to that UI's code). + +const http = require('http'); +const express = require('express'); +const httpProxy = require('http-proxy'); +const { provisioner } = require('./provisioner-client'); +const envCache = require('./env-cache'); + +const PORT = Number(process.env.GATEWAY_PORT) || 4200; +const IDENTITY_BACKEND_URL = process.env.IDENTITY_BACKEND_URL || 'http://localhost:7554'; +const LAB_SESSION_COOKIE = 'rn_lab'; +const COOKIE_MAX_AGE_SECONDS = 8 * 60 * 60; // 8h — generous for a work session, not indefinite + +const app = express(); +// Deliberately NOT using express.json() globally: none of this Gateway's own routes read +// req.body (they only use headers/params/query), and body-parsing middleware consumes the +// request stream — which breaks http-proxy's re-streaming of proxied POST bodies (e.g. +// /api/roster) to the identity backend, causing them to hang forever waiting for a body that +// already got read. Found by testing: POST /api/roster through the Gateway hung indefinitely +// until this was removed. +const proxy = httpProxy.createProxyServer({ ws: true }); +proxy.on('error', (err, req, res) => { + console.error('[proxy error]', err.message); + if (res && !res.headersSent && res.writeHead) { + res.writeHead(502, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'lab_unreachable', message: 'The lab environment is not reachable right now.' })); + } else if (res && res.destroy) { + res.destroy(); // WS socket case + } +}); + +// ── Cookie helpers (no dependency — a handful of lines, not worth pulling in cookie-parser) ─ +function parseCookies(req) { + const header = req.headers.cookie; + if (!header) return {}; + return Object.fromEntries( + header.split(';').map((p) => p.trim().split('=')).filter((p) => p.length === 2).map(([k, v]) => [k, decodeURIComponent(v)]) + ); +} +function setLabSessionCookie(res, token) { + res.append('Set-Cookie', `${LAB_SESSION_COOKIE}=${encodeURIComponent(token)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${COOKIE_MAX_AGE_SECONDS}`); +} + +// ── Environment resolution ─────────────────────────────────────────────────────────────── +// Never trusts a token alone for anything beyond "which container to proxy to" — ownership +// checks (student_id match) happen separately, at the API layer, per docs/PHASE2A_*.md +// "Security requirements": token-alone, student-id-alone, and env-id-alone are all explicitly +// documented as insufficient for production auth; this is pilot-grade, stated as such. +async function resolveByToken(token) { + const cached = envCache.get(token); + if (cached) return cached; + const { status, body } = await provisioner.getEnvironmentByToken(token); + if (status !== 200) return null; + envCache.set(token, body); + return body; +} + +function isReachable(env) { + return env && env.status === 'running' && env.internal_ip; +} + +// ── /api/environment/* — student-facing glue, calls the Provisioner internally ───────────── +const studentIdPattern = /^[a-zA-Z0-9_.-]+$/; +function requireStudentId(req, res) { + const studentId = req.get('X-Student-Id'); + if (!studentId || !studentIdPattern.test(studentId)) { + res.status(400).json({ error: 'missing_student_id', message: 'X-Student-Id header is required.' }); + return null; + } + return studentId; +} + +// env.routing_token is redacted ('[redacted]') on every Provisioner read path except the +// initial POST /environments response and the dedicated GET /environments/:id/token (see +// provisioner-client.js and the Provisioner's own server.js comment on that route) — so +// building a launch_path for anything other than a just-created environment means fetching +// the real token separately. Still never sent anywhere except this one field, for this one +// purpose. +async function publicEnvView(env) { + if (!env) return { status: 'none' }; + // Deliberately minimal otherwise: never send internal_ip, container_id, network info, + // lab_host_id, or environment_id to the browser. See + // docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md "Security requirements." + const view = { status: env.status }; + if (env.status === 'running') { + let token = env.routing_token && env.routing_token !== '[redacted]' ? env.routing_token : null; + if (!token) { + const { status, body } = await provisioner.getToken(env.environment_id); + if (status === 200) token = body.routing_token; + } + if (token) view.launch_path = `/lab/${token}/`; + } + if (env.status === 'error') view.error_reason = 'Lab failed to start. You can try launching again.'; + return view; +} + +app.get('/api/environment/status', async (req, res) => { + const studentId = requireStudentId(req, res); + if (!studentId) return; + const { status, body } = await provisioner.listForStudent(studentId); + if (status !== 200) return res.status(502).json({ error: 'provisioner_unreachable' }); + const active = body.find((e) => !['destroyed', 'error'].includes(e.status)) || body.find((e) => e.status === 'error'); + res.json(await publicEnvView(active)); +}); + +app.post('/api/environment/launch', async (req, res) => { + const studentId = requireStudentId(req, res); + if (!studentId) return; + + const existing = await provisioner.listForStudent(studentId); + const active = existing.body?.find((e) => ['requested', 'provisioning', 'running', 'resetting'].includes(e.status)); + if (active) { + // Reuse — "check whether they already have a usable environment; if yes, reuse it." + return res.json(await publicEnvView(active)); + } + + const { status, body } = await provisioner.provision(studentId); + if (status === 409) { + // Race: someone else's concurrent request created one between our check and this call. + const retry = await provisioner.listForStudent(studentId); + return res.json(await publicEnvView(retry.body?.[0])); + } + if (status !== 201) { + return res.status(502).json({ error: 'provision_failed', message: body?.error || 'Failed to provision an environment.' }); + } + // The un-redacted token IS available here directly (body.routing_token, from the fresh + // POST /environments response) — publicEnvView still works fine even without it since it + // falls back to the dedicated lookup, but passing it through avoids one extra round trip. + res.json(await publicEnvView({ environment_id: body.environment_id, status: body.status, routing_token: body.routing_token })); +}); + +app.post('/api/environment/reset', async (req, res) => { + const studentId = requireStudentId(req, res); + if (!studentId) return; + const existing = await provisioner.listForStudent(studentId); + const env = existing.body?.find((e) => !['destroyed'].includes(e.status)); + if (!env) return res.status(404).json({ error: 'no_environment', message: 'No environment to reset — launch one first.' }); + if (env.student_id !== studentId) { + // Defense in depth — listForStudent is already scoped by student_id, so this should be + // unreachable, but ownership is verified explicitly rather than assumed from the query. + return res.status(403).json({ error: 'forbidden' }); + } + // env.routing_token is redacted here (from listForStudent) — fetch the real one so the + // cache actually gets invalidated under the key requests are made with, not under the + // literal string '[redacted]'. Otherwise a request to the (unchanged, per reset semantics) + // token could serve a stale cached internal_ip for up to the cache TTL after reset. + const { body: tokenBody } = await provisioner.getToken(env.environment_id); + if (tokenBody?.routing_token) envCache.invalidate(tokenBody.routing_token); + + const { status, body } = await provisioner.reset(env.environment_id); + if (status !== 200) return res.status(502).json({ error: 'reset_failed', message: body?.error }); + res.json(await publicEnvView(body)); +}); + +// ── Progress relay — the one place the Gateway does more than blind proxying ─────────────── +// Scenario validation must run against the student's own live cluster (inside their Lab +// container) — it can't be re-run anywhere else. But the canonical progress record lives on +// the persistent identity instance (docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md §1/§9 note on why +// the ephemeral Lab's own SQLite can't be where progress lives). So these two routes are +// handled explicitly instead of falling through to the blind /lab/:token proxy below: forward +// the real request to the Lab, return its real response to the browser unchanged, and — only +// on success — separately relay the outcome to the identity backend's progress store. Found +// necessary by testing: without this, a scenario solved in the Lab UI never showed up in the +// dashboard's curriculum progress at all, and would have been lost outright on lab reset. +async function relayScenarioOutcome({ env, req, res, scenarioPath, extractOutcome }) { + if (!isReachable(env)) return sendLabError(res, 503, 'lab_unavailable', 'Your lab is not currently running.'); + const labRes = await fetch(`http://${env.internal_ip}:${env.internal_port}${scenarioPath}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(req.body || {}), + }); + const body = await labRes.json().catch(() => ({})); + res.status(labRes.status).json(body); // real response to the browser, unchanged + + const passed = extractOutcome(body); + if (passed !== null) { + fetch(`${IDENTITY_BACKEND_URL}/api/progress/record`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Student-Id': env.student_id }, + body: JSON.stringify({ scenario_id: req.params.scenarioId, status: passed ? 'completed' : 'in_progress' }), + }).catch((e) => console.error('[progress relay] failed:', e.message)); + } +} + +app.post('/lab/:token/api/scenarios/:scenarioId/validate', express.json(), async (req, res) => { + const env = await resolveByToken(req.params.token); + if (!env) return sendLabError(res, 404, 'invalid_token', 'This lab link is invalid or has expired.'); + await relayScenarioOutcome({ + env, req, res, scenarioPath: `/api/scenarios/${req.params.scenarioId}/validate`, + extractOutcome: (body) => (typeof body.passed === 'boolean' ? body.passed : null), + }); +}); + +app.post('/lab/:token/api/scenarios/:scenarioId/answer', express.json(), async (req, res) => { + const env = await resolveByToken(req.params.token); + if (!env) return sendLabError(res, 404, 'invalid_token', 'This lab link is invalid or has expired.'); + await relayScenarioOutcome({ + env, req, res, scenarioPath: `/api/scenarios/${req.params.scenarioId}/answer`, + extractOutcome: (body) => (typeof body.correct === 'boolean' ? body.correct : null), + }); +}); + +// ── /lab/:token/* — HTTP proxy into the student's own Lab, prefix-stripped ───────────────── +app.use('/lab/:token', async (req, res, next) => { + const { token } = req.params; + const env = await resolveByToken(token); + + if (!env) return sendLabError(res, 404, 'invalid_token', 'This lab link is invalid or has expired.'); + + const sid = req.query.sid; + if (sid && env.student_id !== sid) { + // Explicit ownership check when we have something to check against (see "Routing design" + // in docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md for why sid is only available as a query + // param on the initial navigation, not on every subsequent proxied request). + return sendLabError(res, 403, 'forbidden', 'This lab does not belong to your session.'); + } + if (env.status === 'provisioning' || env.status === 'requested' || env.status === 'resetting') { + return sendLabError(res, 202, 'provisioning', 'Your lab is still starting. Try again in a moment.'); + } + if (!isReachable(env)) { + return sendLabError(res, 503, 'lab_unavailable', 'Your lab is not currently running. Try recreating it.'); + } + + setLabSessionCookie(res, token); + req.url = req.url.replace(/^\/lab\/[^/]+/, '') || '/'; + proxy.web(req, res, { target: `http://${env.internal_ip}:${env.internal_port}` }); +}); + +function sendLabError(res, status, error, message) { + res.status(status).type('html').send(` +RootNode Academy + +

${message}

+

(${error})

+`); +} + +// ── Default fallback — cookie-routed Lab traffic, or the identity/platform backend ───────── +app.use(async (req, res) => { + const cookies = parseCookies(req); + const token = cookies[LAB_SESSION_COOKIE]; + if (token) { + const env = await resolveByToken(token); + if (isReachable(env)) { + return proxy.web(req, res, { target: `http://${env.internal_ip}:${env.internal_port}` }); + } + } + proxy.web(req, res, { target: IDENTITY_BACKEND_URL }); +}); + +// ── WebSocket upgrade — same routing rules as HTTP, applied to the /shell-ws handshake ───── +const server = http.createServer(app); +server.on('upgrade', async (req, socket, head) => { + const labMatch = req.url.match(/^\/lab\/([^/]+)(\/.*)?$/); + if (labMatch) { + const [, token, rest] = labMatch; + const env = await resolveByToken(token); + if (!isReachable(env)) return socket.destroy(); + req.url = rest || '/'; + return proxy.ws(req, socket, head, { target: `http://${env.internal_ip}:${env.internal_port}` }); + } + + const cookies = parseCookies(req); + const token = cookies[LAB_SESSION_COOKIE]; + if (token) { + const env = await resolveByToken(token); + if (isReachable(env)) { + return proxy.ws(req, socket, head, { target: `http://${env.internal_ip}:${env.internal_port}` }); + } + } + socket.destroy(); +}); + +server.listen(PORT, () => console.log(`Gateway listening on :${PORT}`)); diff --git a/platform/provisioner/src/server.js b/platform/provisioner/src/server.js index ba4072c..ba922fc 100644 --- a/platform/provisioner/src/server.js +++ b/platform/provisioner/src/server.js @@ -125,6 +125,29 @@ app.get('/environments', asyncRoute(async (req, res) => { res.json(envs.map(redactRow)); })); +// GET /environments/by-token/:token — Phase 2A addition, used by the Gateway to resolve a +// student's opaque routing token to the environment it addresses (internal_ip, status, +// owning student_id) before proxying. Internal-only, same shared-secret auth as every other +// route here — the Gateway is the only caller, browsers never reach this directly. +app.get('/environments/by-token/:token', asyncRoute(async (req, res) => { + const { rows } = await getPool().query('SELECT * FROM environments WHERE routing_token = $1', [req.params.token]); + if (!rows.length) return res.status(404).json({ error: 'not found', request_id: req.requestId }); + res.json(redactRow(rows[0])); +})); + +// GET /environments/:id/token — Phase 2A addition. Every other read path deliberately redacts +// routing_token (see redactRow, and the "never log secrets in plaintext" requirement) — this +// is the one narrow exception, for the Gateway's own legitimate need to reconstruct a +// student's /lab// launch URL for an environment that already exists (not just one it +// just created, where the token comes back un-redacted from POST /environments directly). +// Still shared-secret gated like every other route — nothing about this is student-reachable. +app.get('/environments/:id/token', asyncRoute(async (req, res) => { + if (!isValidUUID(req.params.id)) return res.status(400).json({ error: 'invalid environment id', request_id: req.requestId }); + const env = await lifecycle.getEnvironment(getPool(), req.params.id); + if (!env) return res.status(404).json({ error: 'not found', request_id: req.requestId }); + res.json({ environment_id: env.environment_id, routing_token: env.routing_token }); +})); + // GET /environments/:id app.get('/environments/:id', asyncRoute(async (req, res) => { if (!isValidUUID(req.params.id)) return res.status(400).json({ error: 'invalid environment id', request_id: req.requestId });