feat(routing): Lab Host Router closes the Gateway-to-Lab cross-VM gap

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.
This commit is contained in:
2026-08-24 20:14:39 -05:00
parent bf1e1b5c76
commit eae8e48d43
27 changed files with 1431 additions and 68 deletions
+34 -15
View File
@@ -19,6 +19,11 @@ 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';
@@ -90,6 +95,9 @@ async function resolveByToken(token) {
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;
}
@@ -116,9 +124,9 @@ async function checkOwnership(req, env) {
// ── 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, req, res, scenarioPath, extractOutcome }) {
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 fetch(`http://${env.internal_ip}:${env.internal_port}${scenarioPath}`, {
const labRes = await labRequest(env, token, scenarioPath, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(req.body || {}),
@@ -157,7 +165,7 @@ app.post('/lab/:token/api/scenarios/:scenarioId/validate', express.json(), async
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, req, res, scenarioPath: `/api/scenarios/${req.params.scenarioId}/validate`,
env, token: req.params.token, req, res, scenarioPath: `/api/scenarios/${req.params.scenarioId}/validate`,
extractOutcome: (body) => (typeof body.passed === 'boolean' ? body.passed : null),
});
});
@@ -167,7 +175,7 @@ app.post('/lab/:token/api/scenarios/:scenarioId/answer', express.json(), async (
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, req, res, scenarioPath: `/api/scenarios/${req.params.scenarioId}/answer`,
env, token: req.params.token, req, res, scenarioPath: `/api/scenarios/${req.params.scenarioId}/answer`,
extractOutcome: (body) => (typeof body.correct === 'boolean' ? body.correct : null),
});
});
@@ -223,7 +231,7 @@ app.get('/lab/:token/api/scenarios/:scenarioId', async (req, res) => {
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 fetch(`http://${env.internal_ip}:${env.internal_port}/api/scenarios/${req.params.scenarioId}`);
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 });
@@ -259,7 +267,8 @@ app.use('/lab/:token', async (req, res, next) => {
// for this browser.
res.append('Set-Cookie', `${LAB_ACTIVE_COOKIE}=1; Path=/; HttpOnly; SameSite=Lax; Max-Age=28800`);
req.url = req.url.replace(/^\/lab\/[^/]+/, '') || '/';
proxy.web(req, res, { target: `http://${env.internal_ip}:${env.internal_port}` });
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
@@ -268,20 +277,23 @@ app.use('/lab/:token', async (req, res, next) => {
// 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 token = cookies[STUDENT_COOKIE];
if (!token) 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}=${token}` } });
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 : null;
return isReachable(env) && env.student_id === studentId ? { env, token: lookupToken } : null;
} catch {
return null;
}
@@ -303,8 +315,11 @@ function looksLikeLabPath(pathname) {
// or the Platform API (student dashboard, admin portal, roster/curriculum/progress) ────────
app.use(async (req, res) => {
if (looksLikeLabPath(req.path)) {
const env = await resolveOwnedRunningEnvironment(req);
if (env) return proxy.web(req, res, { target: `http://${env.internal_ip}:${env.internal_port}` });
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 });
});
@@ -319,12 +334,16 @@ server.on('upgrade', async (req, socket, head) => {
const own = await checkOwnership(req, env);
if (!own.ok || !isReachable(env)) return socket.destroy();
req.url = rest || '/';
return proxy.ws(req, socket, head, { target: `http://${env.internal_ip}:${env.internal_port}` });
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 env = await resolveOwnedRunningEnvironment(req);
if (env) return proxy.ws(req, socket, head, { target: `http://${env.internal_ip}:${env.internal_port}` });
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();
});