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.
104 lines
4.8 KiB
JavaScript
104 lines
4.8 KiB
JavaScript
// 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)`));
|