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
+128
View File
@@ -0,0 +1,128 @@
// Phase 4.1 — the ONE place the Gateway decides how to reach a student's Lab. Everything else
// in server.js just calls labTarget(env, token) and gets back a plain { url, headers, agent }
// it can hand to http-proxy or fetch, with zero knowledge of Docker, VMs, or lab_hosts. See
// docs/PHASE4_1_PREPRODUCTION_HARDENING.md §1 and docs/PRODUCTION_NETWORK_SECURITY.md.
//
// env.lab_host_routing_mode / env.lab_host_router_endpoint come from the Provisioner's
// by-token response (a join against lab_hosts — see provisioner lifecycle.js
// getEnvironmentByTokenWithRouting). Two modes:
//
// 'direct' (default, dev/single-host) — unchanged from every prior phase: the Gateway
// reaches env.internal_ip:internal_port itself, because Gateway and the Lab share a Docker
// daemon/network namespace.
//
// 'router' (production, two real VMs) — the Gateway never touches a Docker-internal
// address. It reaches that lab_host's Lab Host Router (platform/lab-router) instead, over
// mTLS, carrying the environment's routing_token in a header so the Router — which lives on
// the SAME host as the Lab and can resolve the token from local `docker ps` alone — knows
// which local container to proxy to.
const fs = require('fs');
const http = require('http');
const https = require('https');
const { URL } = require('url');
const ROUTE_TOKEN_HEADER = 'x-rootnode-route-token';
// mTLS client identity the Gateway presents to a Lab Host Router. One shared cert/key/ca is
// enough for Phase 4.1's single lab_host; if a second lab_host ever needs a distinct client
// identity, this becomes a map keyed by lab_host_id — not needed yet (see docs
// "multi-host-ready" note, no scheduler built here).
const TLS_CA = process.env.LAB_ROUTER_TLS_CA;
const TLS_CERT = process.env.LAB_ROUTER_TLS_CERT;
const TLS_KEY = process.env.LAB_ROUTER_TLS_KEY;
let _agent = null;
function routerAgent() {
if (_agent) return _agent;
if (!TLS_CA || !TLS_CERT || !TLS_KEY) {
throw new Error(
'A lab_host is configured with routing_mode=router but LAB_ROUTER_TLS_CA/CERT/KEY are not set — refusing to connect to the Lab Host Router unauthenticated.'
);
}
_agent = new https.Agent({
ca: fs.readFileSync(TLS_CA),
cert: fs.readFileSync(TLS_CERT),
key: fs.readFileSync(TLS_KEY),
// The Router's server cert is issued by the same private CA (infra/scripts/docker-tls/
// generate-docker-tls.sh) — never fall back to the public CA trust store for this.
});
return _agent;
}
// env: the (possibly-cached) environment row from the Provisioner's by-token response.
// token: the routing token the browser's URL already carried (Gateway never needs the
// Provisioner to hand it back — see provisioner server.js redactRow's comment on why
// routing_token is redacted everywhere except the two routes that legitimately need it,
// neither of which is this one).
function labTarget(env, token) {
if (env.lab_host_routing_mode === 'router') {
if (!env.lab_host_router_endpoint) {
throw new Error(`lab_host '${env.lab_host_id}' has routing_mode=router but no router_endpoint`);
}
return {
url: env.lab_host_router_endpoint,
headers: { [ROUTE_TOKEN_HEADER]: token },
agent: routerAgent(),
mode: 'router',
// The browser's original Host header (e.g. "localhost:4210" or the real public origin)
// has nothing to do with the Router's own TLS identity — without this, http-proxy's
// outgoing HTTPS connection inherits that unrelated Host header for SNI/hostname
// verification and the Router's cert (correctly issued for ITS OWN name) fails to
// match. changeOrigin makes http-proxy rewrite the outgoing Host header (and therefore
// the TLS servername) to match the actual target — found by testing against the real
// Phase 4.1 simulation harness, not theoretical.
changeOrigin: true,
};
}
// direct — unchanged behavior from every phase before this one.
return { url: `http://${env.internal_ip}:${env.internal_port}`, headers: {}, agent: undefined, mode: 'direct', changeOrigin: false };
}
// Used by the two Gateway call sites that need to make a one-shot request to the Lab and read
// its JSON body (progress/answer/validate relay, hint-count stripping) — everywhere else
// (the actual proxy paths) uses http-proxy directly with the same target/agent/headers.
//
// Deliberately built on node:http/node:https rather than global fetch: fetch's undici
// dispatcher isn't a stable, dependency-free way to attach a custom mTLS agent across Node
// versions, and http.request/https.request support client certs natively with zero new
// dependencies (stdlib covers it — see the ladder in CLAUDE.md's coding-style guidance).
// Mimics just enough of fetch's response shape ({ status, json() }) that call sites barely
// change.
function labRequest(env, token, path, { method = 'GET', headers = {}, body } = {}) {
const target = labTarget(env, token);
const url = new URL(path, target.url);
const transport = url.protocol === 'https:' ? https : http;
const payload = body === undefined ? undefined : (typeof body === 'string' ? body : JSON.stringify(body));
return new Promise((resolve, reject) => {
const req = transport.request(
url,
{
method,
agent: target.agent,
headers: {
...headers,
...target.headers,
...(payload !== undefined ? { 'Content-Length': Buffer.byteLength(payload) } : {}),
},
},
(res) => {
const chunks = [];
res.on('data', (c) => chunks.push(c));
res.on('end', () => {
const text = Buffer.concat(chunks).toString('utf8');
resolve({
status: res.statusCode,
json: async () => JSON.parse(text),
});
});
}
);
req.on('error', reject);
if (payload !== undefined) req.write(payload);
req.end();
});
}
module.exports = { labTarget, labRequest, ROUTE_TOKEN_HEADER };
+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();
});