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