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.
129 lines
5.9 KiB
JavaScript
129 lines
5.9 KiB
JavaScript
// 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 };
|