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
+29
View File
@@ -0,0 +1,29 @@
// Token -> local container resolution, split out from server.js so it can be unit-tested
// against a real (or fake) Docker client without needing TLS material or a bound port.
// See server.js's header comment for the full design rationale.
const ROUTING_TOKEN_LABEL = 'rootnode.routing-token';
function tokenFingerprint(token) {
return token ? `${token.slice(0, 6)}…(${token.length})` : '(none)';
}
// Resolves a routing token to a live local container's reachable base URL, purely from local
// Docker state — no Postgres, no Provisioner call. Returns null for "no such token" AND "token
// belonged to a destroyed/replaced container" — both cases the caller treats identically (404).
async function resolveTarget(docker, token) {
if (!token || typeof token !== 'string' || token.length < 16) return null; // reject garbage before even hitting Docker
const containers = await docker.listContainers({
filters: JSON.stringify({ label: [`${ROUTING_TOKEN_LABEL}=${token}`], status: ['running'] }),
});
if (containers.length !== 1) return null; // 0 = unknown/destroyed; >1 should never happen (label is per-env-unique) but fail closed either way
const info = containers[0];
const networks = info.NetworkSettings && info.NetworkSettings.Networks;
if (!networks) return null;
const netName = Object.keys(networks)[0];
const ip = netName && networks[netName].IPAddress;
if (!ip) return null;
return `http://${ip}:80`; // every Lab container listens on 80 internally — see docs/LAB_ARCHITECTURE.md
}
module.exports = { resolveTarget, tokenFingerprint, ROUTING_TOKEN_LABEL };
+103
View File
@@ -0,0 +1,103 @@
// RootNode Academy — Lab Host Router (Phase 4.1).
//
// Runs on the SAME Docker host as student Labs (rn-lab-host-01 in production). Its only job:
// take a request from the Platform Gateway carrying an opaque routing token, find the ONE
// local container labeled with that token, and reverse-proxy the request to it. See
// docs/PHASE4_1_PREPRODUCTION_HARDENING.md §1-3 and docs/PRODUCTION_NETWORK_SECURITY.md.
//
// Deliberately narrow:
// - Never talks to Postgres, the Provisioner, or anything off this host — it resolves purely
// from local `docker ps` state (containers are labeled by the Provisioner at creation time,
// see provisioner lifecycle.js), so a destroyed/reset environment's old token simply has no
// matching container anymore. No separate "is this token still valid" check is needed.
// - Only one route exists: proxy-by-token. No container listing, no Docker-API passthrough,
// no endpoint that echoes back a container id, IP, or network name to the caller.
// - Requires mTLS — the Node https server itself rejects any connection that doesn't present
// a certificate signed by the configured CA (requestCert + rejectUnauthorized), so an
// unauthenticated caller never reaches route-resolution logic at all. This is the ONLY
// authentication; there is no bearer-token/API-key fallback (mTLS or nothing).
// - Binds to an internal address only — this process has no idea what "public" means, that's
// enforced by the firewall (infra/firewall/rn-lab-host-01.rules) and by never being given a
// public-facing port mapping in the production Compose file. Defense in depth: even if the
// firewall were misconfigured, mTLS still blocks anyone without the Gateway's client cert.
const fs = require('fs');
const https = require('https');
const httpProxy = require('http-proxy');
const Docker = require('dockerode');
const { resolveTarget, tokenFingerprint } = require('./resolve');
const PORT = Number(process.env.LAB_ROUTER_PORT) || 8443;
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;
const ROUTE_TOKEN_HEADER = 'x-rootnode-route-token';
if (!TLS_CA || !TLS_CERT || !TLS_KEY) {
console.error('FATAL: LAB_ROUTER_TLS_CA/CERT/KEY are not set. Refusing to start an unauthenticated Lab Host Router.');
process.exit(1);
}
// Always the local socket — the Router only ever inspects containers on ITS OWN host, never a
// remote Docker daemon (that's the Provisioner's job, over a completely separate mTLS
// connection — see docs/PHASE4_1_PREPRODUCTION_HARDENING.md §10 "control plane vs data plane").
const docker = new Docker({ socketPath: process.env.DOCKER_SOCKET || '/var/run/docker.sock' });
const proxy = httpProxy.createProxyServer({ ws: true });
proxy.on('error', (err, req, res) => {
console.error('[lab-router] proxy error:', err.message);
if (res && res.writeHead && !res.headersSent) {
res.writeHead(502, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'lab_unreachable' }));
} else if (res && res.destroy) {
res.destroy();
}
});
async function handleRequest(req, res) {
const token = req.headers[ROUTE_TOKEN_HEADER];
let target;
try {
target = await resolveTarget(docker, token);
} catch (e) {
console.error('[lab-router] docker lookup failed:', e.message);
res.writeHead(502, { 'Content-Type': 'application/json' });
return res.end(JSON.stringify({ error: 'router_internal_error' }));
}
if (!target) {
console.log(`[lab-router] unknown/stale token ${tokenFingerprint(token)} -> 404`);
res.writeHead(404, { 'Content-Type': 'application/json' });
return res.end(JSON.stringify({ error: 'unknown_environment' }));
}
proxy.web(req, res, { target });
}
async function handleUpgrade(req, socket, head) {
const token = req.headers[ROUTE_TOKEN_HEADER];
let target;
try {
target = await resolveTarget(docker, token);
} catch (e) {
console.error('[lab-router] docker lookup failed (ws):', e.message);
return socket.destroy();
}
if (!target) {
console.log(`[lab-router] unknown/stale token ${tokenFingerprint(token)} -> ws rejected`);
return socket.destroy();
}
proxy.ws(req, socket, head, { target });
}
const tlsOptions = {
ca: fs.readFileSync(TLS_CA),
cert: fs.readFileSync(TLS_CERT),
key: fs.readFileSync(TLS_KEY),
requestCert: true,
rejectUnauthorized: true, // the entire authentication mechanism — see file header
};
const server = https.createServer(tlsOptions, handleRequest);
server.on('upgrade', handleUpgrade);
server.on('tlsClientError', (err) => console.warn('[lab-router] rejected unauthenticated/invalid TLS client:', err.message));
server.listen(PORT, () => console.log(`Lab Host Router listening on :${PORT} (mTLS required)`));