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:
@@ -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 };
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user