// 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 };