Adds platform/lab-router/ — a narrow service that runs on the Lab Host, authenticates the Gateway via mTLS, and resolves an opaque routing token to a local container purely from Docker label state (no Postgres access, no callback to the Provisioner). Replaces the Gateway's env.internal_ip assumption (only valid when Gateway and Lab share a Docker daemon) with a data-driven direct/router decision per lab_host (lab_hosts.routing_mode/router_endpoint, migration 004), resolved in one place (platform/gateway/src/lab-endpoint.js's labTarget()) that every proxy/fetch call site in server.js now goes through. Extends the existing Docker TLS CA (infra/scripts/docker-tls/generate-docker-tls.sh) with a second mTLS leaf pair (router-server/gateway-client) for this new data-plane connection, deployed by the docker-tls Ansible role into a separate directory from the control-plane pair. Firewall (rn-lab-host-01.rules) replaces the old speculative wide port-range allowance with one narrow rule: platform_ip -> the Router's port only. dev-local stays routing_mode: direct — zero behavior change for local dev, confirmed by the full existing regression suite passing unmodified.
351 lines
19 KiB
JavaScript
351 lines
19 KiB
JavaScript
// RootNode Academy — Gateway (Phase 2B revision).
|
|
//
|
|
// The only student-facing path to disposable Lab environments. Phase 2B changes from Phase 2A:
|
|
// - No longer calls the Provisioner directly. All environment orchestration (student launch/
|
|
// reset, admin prepare/destroy/reset) is consolidated in the Platform API
|
|
// (platform/api) — this Gateway proxies /api/environment/*, /api/admin/*, and everything
|
|
// else to it, the same way it proxied to the old "identity" instance in Phase 2A.
|
|
// - Lab ownership is no longer decided by a client-supplied ?sid= query param (Phase 2A
|
|
// pilot mechanism, trivially forgeable). It's derived from the browser's rn_session cookie,
|
|
// resolved server-side via the Platform API's session store
|
|
// (GET /api/session/whoami) — nothing client-controlled (URL, token, query param, header)
|
|
// can change who the Gateway thinks is asking. See
|
|
// docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md §3.
|
|
// - Ownership is verified on EVERY proxied Lab request (HTTP and WebSocket), not just the
|
|
// initial navigation — a short-TTL cache keeps this cheap.
|
|
|
|
const http = require('http');
|
|
const express = require('express');
|
|
const httpProxy = require('http-proxy');
|
|
const { provisioner } = require('./provisioner-client'); // still used ONLY for the token->environment lookup a Lab request needs; all mutating calls (provision/reset/destroy) now live in the Platform API.
|
|
const envCache = require('./env-cache');
|
|
// Phase 4.1 — the ONE place that knows how to reach a Lab (direct Docker-internal address, or
|
|
// via that lab_host's Lab Host Router). Every proxy.web/proxy.ws/labRequest call below uses
|
|
// labTarget's output instead of building `http://${env.internal_ip}:${env.internal_port}`
|
|
// itself. See lab-endpoint.js's header comment and docs/PHASE4_1_PREPRODUCTION_HARDENING.md §1.
|
|
const { labTarget, labRequest } = require('./lab-endpoint');
|
|
|
|
const PORT = Number(process.env.GATEWAY_PORT) || 4200;
|
|
const PLATFORM_API_URL = process.env.PLATFORM_API_URL || 'http://localhost:4300';
|
|
const STUDENT_COOKIE = 'rn_session';
|
|
const STUDENT_CSRF_COOKIE = 'rn_csrf_s'; // Phase 3A — see relayScenarioOutcome for why the Gateway needs this too
|
|
// Routing hint only — NOT a security check by itself (ownership is always re-verified via the
|
|
// session, see checkOwnership/resolveOwnedRunningEnvironment). Its only job is disambiguating
|
|
// "this unprefixed request came from inside the Lab UI" from "this came from the dashboard,"
|
|
// which "does the student currently have a running env" cannot do — that's true on the
|
|
// dashboard too, and naively using it hijacked EVERY unmatched dashboard request (including
|
|
// GET /api/session/whoami) to the student's Lab container once one existed. Found by testing.
|
|
const LAB_ACTIVE_COOKIE = 'rn_lab_active';
|
|
// Phase 3A: shortened from 5000ms so a revoked/expired session stops working here within a
|
|
// tighter, testable bound (see platform/test/e2e-phase3a.js "revoked mid-use" case).
|
|
const SESSION_CACHE_TTL_MS = 2000;
|
|
|
|
const app = express();
|
|
// Deliberately no express.json() globally — see the Phase 2A note this carries forward:
|
|
// body-parsing middleware breaks http-proxy's re-streaming of proxied POST bodies.
|
|
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: 'unreachable' }));
|
|
} else if (res && res.destroy) {
|
|
res.destroy();
|
|
}
|
|
});
|
|
|
|
// GET /healthz — the Gateway's own liveness check, answered locally without proxying anywhere
|
|
// (Phase 4, docs/OBSERVABILITY.md §"uptime checks"). Deliberately doesn't depend on Platform
|
|
// API/Provisioner being reachable, so it can distinguish "Gateway itself is down" from
|
|
// "something behind it is down" for alerting.
|
|
app.get('/healthz', (req, res) => res.json({ ok: true }));
|
|
|
|
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)])
|
|
);
|
|
}
|
|
|
|
// ── Session resolution — replaces Phase 2A's ?sid= entirely ────────────────────────────────
|
|
const sessionCache = new Map(); // cookie value -> { studentId, expiresAt }
|
|
async function resolveSessionStudentId(req) {
|
|
const token = parseCookies(req)[STUDENT_COOKIE];
|
|
if (!token) return null;
|
|
const cached = sessionCache.get(token);
|
|
if (cached && cached.expiresAt > Date.now()) return cached.studentId;
|
|
try {
|
|
const res = await fetch(`${PLATFORM_API_URL}/api/session/whoami`, { headers: { Cookie: `${STUDENT_COOKIE}=${token}` } });
|
|
if (res.status !== 200) return null;
|
|
const { student_id } = await res.json();
|
|
sessionCache.set(token, { studentId: student_id, expiresAt: Date.now() + SESSION_CACHE_TTL_MS });
|
|
return student_id;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
// internal_ip is set by the Provisioner as soon as a container exists, regardless of routing
|
|
// mode (it's a Docker-internal fact, independent of how the Gateway will reach it) — so this
|
|
// check stays valid unchanged for both 'direct' and 'router' lab_hosts.
|
|
function isReachable(env) {
|
|
return env && env.status === 'running' && env.internal_ip;
|
|
}
|
|
|
|
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>`);
|
|
}
|
|
|
|
// Shared ownership check: valid session AND session's student owns this environment.
|
|
// Used identically by the explicit /lab/:token route, the cookie-fallback route, and the
|
|
// WebSocket upgrade handler — one rule, everywhere a Lab request could be routed.
|
|
async function checkOwnership(req, env) {
|
|
if (!env) return { ok: false, reason: 'invalid_token' };
|
|
const studentId = await resolveSessionStudentId(req);
|
|
if (!studentId) return { ok: false, reason: 'no_session' };
|
|
if (studentId !== env.student_id) return { ok: false, reason: 'forbidden' };
|
|
return { ok: true, studentId };
|
|
}
|
|
|
|
// ── Progress relay (unchanged mechanism from Phase 2A, now passes environment_id +
|
|
// validation_count through so Platform API can log a real scenario_attempts row) ──────────
|
|
async function relayScenarioOutcome({ env, token, req, res, scenarioPath, extractOutcome }) {
|
|
if (!isReachable(env)) return sendLabError(res, 503, 'lab_unavailable', 'Your lab is not currently running.');
|
|
const labRes = await labRequest(env, token, 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);
|
|
|
|
const passed = extractOutcome(body);
|
|
if (passed !== null) {
|
|
const validationCount = Array.isArray(body.checks) ? body.checks.length : undefined;
|
|
const cookies = parseCookies(req);
|
|
const token = cookies[STUDENT_COOKIE];
|
|
const csrf = cookies[STUDENT_CSRF_COOKIE] || '';
|
|
// Phase 3A: /api/progress/record is CSRF-protected like every other student mutation. The
|
|
// double-submit check needs BOTH the rn_csrf_s cookie AND a matching X-CSRF-Token header on
|
|
// the request Platform API actually sees — so both are forwarded here, not just the header
|
|
// (an earlier version of this fix sent only the header, which still 403'd).
|
|
fetch(`${PLATFORM_API_URL}/api/progress/record`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Cookie: `${STUDENT_COOKIE}=${token}; ${STUDENT_CSRF_COOKIE}=${csrf}`,
|
|
'X-CSRF-Token': csrf,
|
|
},
|
|
body: JSON.stringify({
|
|
scenario_id: req.params.scenarioId, status: passed ? 'completed' : 'in_progress',
|
|
environment_id: env.environment_id, validation_count: validationCount,
|
|
}),
|
|
}).then((r) => { if (!r.ok) console.error('[progress relay] Platform API rejected the update:', r.status); })
|
|
.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);
|
|
const own = await checkOwnership(req, env);
|
|
if (!own.ok) return sendLabError(res, own.reason === 'invalid_token' ? 404 : 403, own.reason, 'This lab is not available to you.');
|
|
await relayScenarioOutcome({
|
|
env, token: req.params.token, 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);
|
|
const own = await checkOwnership(req, env);
|
|
if (!own.ok) return sendLabError(res, own.reason === 'invalid_token' ? 404 : 403, own.reason, 'This lab is not available to you.');
|
|
await relayScenarioOutcome({
|
|
env, token: req.params.token, req, res, scenarioPath: `/api/scenarios/${req.params.scenarioId}/answer`,
|
|
extractOutcome: (body) => (typeof body.correct === 'boolean' ? body.correct : null),
|
|
});
|
|
});
|
|
|
|
// ── Phase 3B learning relay — /start and /hint are pure Platform API operations, unlike
|
|
// validate/answer above (those call the Lab first, then relay the outcome). The Lab has no
|
|
// concept of attempts, hints-as-tracked-state, or assessments; these forward straight through
|
|
// to Platform API with the browser's own session + CSRF token (same forwarding requirement
|
|
// documented on relayScenarioOutcome above — both cookie AND header, not just the header). ────
|
|
async function relayToPlatformApi(req, res, platformPath, extraBody) {
|
|
const cookies = parseCookies(req);
|
|
const token = cookies[STUDENT_COOKIE];
|
|
const csrf = cookies[STUDENT_CSRF_COOKIE] || '';
|
|
try {
|
|
const platformRes = await fetch(`${PLATFORM_API_URL}${platformPath}`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Cookie: `${STUDENT_COOKIE}=${token}; ${STUDENT_CSRF_COOKIE}=${csrf}`,
|
|
'X-CSRF-Token': csrf,
|
|
},
|
|
body: JSON.stringify({ ...(req.body || {}), ...extraBody }),
|
|
});
|
|
const body = await platformRes.json().catch(() => ({}));
|
|
res.status(platformRes.status).json(body);
|
|
} catch (e) {
|
|
res.status(502).json({ error: 'platform_unreachable' });
|
|
}
|
|
}
|
|
|
|
app.post('/lab/:token/api/scenarios/:scenarioId/start', express.json(), async (req, res) => {
|
|
const env = await resolveByToken(req.params.token);
|
|
const own = await checkOwnership(req, env);
|
|
if (!own.ok) return sendLabError(res, own.reason === 'invalid_token' ? 404 : 403, own.reason, 'This lab is not available to you.');
|
|
await relayToPlatformApi(req, res, `/api/scenarios/${req.params.scenarioId}/start`, { environment_id: env.environment_id });
|
|
});
|
|
|
|
app.post('/lab/:token/api/scenarios/:scenarioId/hint', express.json(), async (req, res) => {
|
|
const env = await resolveByToken(req.params.token);
|
|
const own = await checkOwnership(req, env);
|
|
if (!own.ok) return sendLabError(res, own.reason === 'invalid_token' ? 404 : 403, own.reason, 'This lab is not available to you.');
|
|
await relayToPlatformApi(req, res, `/api/scenarios/${req.params.scenarioId}/hint`, { environment_id: env.environment_id });
|
|
});
|
|
|
|
// Strips `hints` from the Lab's own scenario-detail response before it reaches the browser —
|
|
// the Lab backend still returns the full array (it has no student/attempt concept and was
|
|
// never modified), but hints must only be revealed progressively through the endpoint above.
|
|
// The Lab image itself stays byte-for-byte unmodified; this is a response rewrite at the
|
|
// Gateway, not a Lab change.
|
|
app.get('/lab/:token/api/scenarios/:scenarioId', async (req, res) => {
|
|
const env = await resolveByToken(req.params.token);
|
|
const own = await checkOwnership(req, env);
|
|
if (!own.ok) return sendLabError(res, own.reason === 'invalid_token' ? 404 : 403, own.reason, 'This lab is not available to you.');
|
|
if (!isReachable(env)) return sendLabError(res, 503, 'lab_unavailable', 'Your lab is not currently running.');
|
|
try {
|
|
const labRes = await labRequest(env, req.params.token, `/api/scenarios/${req.params.scenarioId}`);
|
|
const body = await labRes.json().catch(() => ({}));
|
|
const { hints, ...withoutHints } = body;
|
|
res.status(labRes.status).json({ ...withoutHints, hint_count: Array.isArray(hints) ? hints.length : 0 });
|
|
} catch (e) {
|
|
res.status(502).json({ error: 'lab_unreachable' });
|
|
}
|
|
});
|
|
|
|
// ── /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 own = await checkOwnership(req, env);
|
|
if (!own.ok) {
|
|
if (own.reason === 'no_session') return sendLabError(res, 401, 'no_session', 'Please log in.');
|
|
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.');
|
|
}
|
|
|
|
// Marks this browser as "currently inside a lab page" — the only thing the unprefixed
|
|
// fallback route below uses to decide whether to even attempt Lab routing. Path-scoped
|
|
// isn't useful here (the whole point is covering root-relative /api/* and /shell-ws calls
|
|
// the Lab UI makes), so Path=/ with a short lifetime instead — long enough to cover a work
|
|
// session, short enough that leaving the lab page eventually stops privileging Lab-routing
|
|
// for this browser.
|
|
res.append('Set-Cookie', `${LAB_ACTIVE_COOKIE}=1; Path=/; HttpOnly; SameSite=Lax; Max-Age=28800`);
|
|
req.url = req.url.replace(/^\/lab\/[^/]+/, '') || '/';
|
|
const target = labTarget(env, token);
|
|
proxy.web(req, res, { target: target.url, agent: target.agent, headers: target.headers, changeOrigin: target.changeOrigin });
|
|
});
|
|
|
|
// Resolves "the environment this browser's session currently owns and can reach," for the
|
|
// unprefixed fallback case (no /lab/:token in the URL — the existing Lab UI's own root-relative
|
|
// calls once a browser is already inside a lab session). Shared by both the HTTP fallback and
|
|
// the WS upgrade handler so there's exactly one place this logic lives. Gated on
|
|
// LAB_ACTIVE_COOKIE first — see its definition above for why "has a running env" alone isn't
|
|
// a safe signal (it's true on the dashboard too).
|
|
// Returns { env, token } (not just env) — Phase 4.1's labTarget needs the routing token
|
|
// alongside the environment row for router-mode lab_hosts, and this is the only place that
|
|
// derives the token from launch_path rather than the URL, so it has to hand both back.
|
|
async function resolveOwnedRunningEnvironment(req) {
|
|
const cookies = parseCookies(req);
|
|
if (!cookies[LAB_ACTIVE_COOKIE]) return null;
|
|
const sessionToken = cookies[STUDENT_COOKIE];
|
|
if (!sessionToken) return null;
|
|
const studentId = await resolveSessionStudentId(req);
|
|
if (!studentId) return null;
|
|
try {
|
|
const envRes = await fetch(`${PLATFORM_API_URL}/api/environment/status`, { headers: { Cookie: `${STUDENT_COOKIE}=${sessionToken}` } });
|
|
const envInfo = await envRes.json().catch(() => null);
|
|
if (envInfo?.status !== 'running' || !envInfo.launch_path) return null;
|
|
const lookupToken = envInfo.launch_path.split('/')[2];
|
|
const env = await resolveByToken(lookupToken);
|
|
return isReachable(env) && env.student_id === studentId ? { env, token: lookupToken } : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Paths the unmodified Lab UI actually calls unprefixed (see backend/server.js's route list
|
|
// and frontend/src/App.jsx et al.). LAB_ACTIVE_COOKIE alone isn't a safe enough signal — it
|
|
// persists for the rest of the browser's session once a student has EVER visited their lab,
|
|
// which would otherwise keep misrouting dashboard calls (GET /api/session/whoami,
|
|
// /api/environment/*, /api/admin/*, /api/roster*, ...) to the Lab long after the student left
|
|
// it. Found by testing. Everything NOT matching this allowlist always goes to the Platform
|
|
// API, regardless of LAB_ACTIVE_COOKIE.
|
|
const LAB_OWNED_PATHS = [/^\/api\/scenarios\b/, /^\/api\/bundles\b/, /^\/api\/sessions\b/, /^\/api\/cache\b/, /^\/api\/health\b/, /^\/shell-ws$/, /^\/assets\//];
|
|
function looksLikeLabPath(pathname) {
|
|
return LAB_OWNED_PATHS.some((re) => re.test(pathname));
|
|
}
|
|
|
|
// ── Default fallback — unprefixed Lab traffic for a browser already "inside" a lab session,
|
|
// or the Platform API (student dashboard, admin portal, roster/curriculum/progress) ────────
|
|
app.use(async (req, res) => {
|
|
if (looksLikeLabPath(req.path)) {
|
|
const owned = await resolveOwnedRunningEnvironment(req);
|
|
if (owned) {
|
|
const target = labTarget(owned.env, owned.token);
|
|
return proxy.web(req, res, { target: target.url, agent: target.agent, headers: target.headers, changeOrigin: target.changeOrigin });
|
|
}
|
|
}
|
|
proxy.web(req, res, { target: PLATFORM_API_URL });
|
|
});
|
|
|
|
// ── WebSocket upgrade — same ownership rule 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);
|
|
const own = await checkOwnership(req, env);
|
|
if (!own.ok || !isReachable(env)) return socket.destroy();
|
|
req.url = rest || '/';
|
|
const target = labTarget(env, token);
|
|
return proxy.ws(req, socket, head, { target: target.url, agent: target.agent, headers: target.headers, changeOrigin: target.changeOrigin });
|
|
}
|
|
|
|
// Unprefixed WS (the existing Lab UI's /shell-ws, hit while "inside" a lab session).
|
|
const owned = await resolveOwnedRunningEnvironment(req);
|
|
if (owned) {
|
|
const target = labTarget(owned.env, owned.token);
|
|
return proxy.ws(req, socket, head, { target: target.url, agent: target.agent, headers: target.headers, changeOrigin: target.changeOrigin });
|
|
}
|
|
socket.destroy();
|
|
});
|
|
|
|
server.listen(PORT, () => console.log(`Gateway listening on :${PORT}`));
|