// 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)`));