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,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.
|
||||||
@@ -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:
|
||||||
@@ -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"]
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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/<token>/* — 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/<token>/, 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(`<!doctype html><meta charset="utf-8">
|
||||||
|
<title>RootNode Academy</title>
|
||||||
|
<body style="font-family:system-ui;max-width:32rem;margin:4rem auto;padding:0 1rem">
|
||||||
|
<h2>${message}</h2>
|
||||||
|
<p style="color:#666">(${error})</p>
|
||||||
|
</body>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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}`));
|
||||||
@@ -125,6 +125,29 @@ app.get('/environments', asyncRoute(async (req, res) => {
|
|||||||
res.json(envs.map(redactRow));
|
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/<token>/ 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
|
// GET /environments/:id
|
||||||
app.get('/environments/:id', asyncRoute(async (req, res) => {
|
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 });
|
if (!isValidUUID(req.params.id)) return res.status(400).json({ error: 'invalid environment id', request_id: req.requestId });
|
||||||
|
|||||||
Reference in New Issue
Block a user