#!/usr/bin/env node // Phase 4.1 §6-9 — the real, unmocked proof that remote Lab routing works. Run via // run-simulation.sh, which brings up docker-compose.simulate.yml first. No framework, per // project convention (see platform/provisioner/test/run.js). // // Proves, against the real simulated stack: // §6/7 Gateway cannot reach a Lab's Docker-internal IP directly, but reaches it correctly // through the Lab Host Router — both HTTP and WebSocket terminal traffic, including a // real `kubectl get nodes` inside the Lab. // §8/9 Alice/Bob isolation, garbage tokens, destroyed environments, anonymous requests, and // that the Router itself is unreachable without the Gateway's mTLS client identity — // all through the real path, not asserted against mocks. const assert = require('assert'); const https = require('https'); const { execFileSync } = require('child_process'); const { makeSession } = require('../../../platform/test/session-helper'); const GW = process.env.GATEWAY_URL || 'http://localhost:4210'; const PROVISIONER_URL = process.env.PROVISIONER_URL || 'http://localhost:4101'; const PROVISIONER_KEY = process.env.PROVISIONER_SHARED_SECRET; const GATEWAY_CONTAINER = process.env.GATEWAY_CONTAINER || 'rootnode-sim-gateway-1'; const WS_URL = GW.replace(/^http/, 'ws'); let passed = 0, failed = 0; async function test(name, fn) { try { await fn(); console.log(` ok — ${name}`); passed++; } catch (e) { console.error(` FAIL — ${name}\n ${e.stack || e.message}`); failed++; } } async function provisionerCall(method, path) { const res = await fetch(`${PROVISIONER_URL}${path}`, { method, headers: { 'X-Provisioner-Key': PROVISIONER_KEY } }); const body = await res.json().catch(() => null); return { status: res.status, body }; } // The "browser never learns internal_ip" invariant holds everywhere else in the app — this // test is the one deliberate, documented exception: it needs the real Docker-internal address // as ground truth to prove the Gateway genuinely CANNOT reach it, which requires calling the // Provisioner's internal API directly (the same privilege level an operator/CI job has, never // a student's). async function internalAddressForToken(token) { const { body } = await provisionerCall('GET', `/environments/by-token/${token}`); return body; } // bootstrap-owner.js isn't copied into the api image (it's an operator-run one-off script, not // a runtime dependency — same reason platform/test regression runs hit this before, see // docs/PHASE4_PRODUCTION_INFRASTRUCTURE.md's regression notes). Run it from the host against // the sim Postgres's published port instead of `docker exec`ing into the container. function bootstrapOwner(email, name, password) { execFileSync('node', ['bootstrap-owner.js', email, name, password], { cwd: require('path').join(__dirname, '../../../platform/api'), env: { ...process.env, PLATFORM_DB_URL: 'postgres://postgres:postgres@localhost:5434/rootnode_platform_sim' }, stdio: 'inherit', }); } // Raw TCP connect attempt from WITHIN the gateway container's own network namespace — proves // isolation from the gateway's actual point of view, not the test driver's (which runs on the // host and has a completely different, irrelevant reachability picture). function gatewayCanReach(ip, port, timeoutMs = 2500) { const script = ` const net = require('net'); const s = net.createConnection({ host: '${ip}', port: ${port}, timeout: ${timeoutMs} }); s.on('connect', () => { console.log('REACHABLE'); s.destroy(); process.exit(0); }); s.on('timeout', () => { console.log('UNREACHABLE(timeout)'); process.exit(0); }); s.on('error', (e) => { console.log('UNREACHABLE(' + e.code + ')'); process.exit(0); }); `; const out = execFileSync('docker', ['exec', GATEWAY_CONTAINER, 'node', '-e', script]).toString().trim(); return out.startsWith('REACHABLE'); } function connectRouterWithoutClientCert(host, port, timeoutMs = 2500) { return new Promise((resolve) => { const req = https.request({ host, port, method: 'GET', rejectUnauthorized: false, timeout: timeoutMs }, (res) => { resolve({ ok: true, status: res.statusCode }); }); req.on('error', (e) => resolve({ ok: false, error: e.code || e.message })); req.on('timeout', () => { req.destroy(); resolve({ ok: false, error: 'timeout' }); }); req.end(); }); } function openTerminal(launchPath, cookieHeader) { return new Promise((resolve, reject) => { const ws = new WebSocket(`${WS_URL}${launchPath}shell-ws`, { headers: { Cookie: cookieHeader } }); let buffer = ''; const timer = setTimeout(() => { ws.close(); reject(new Error(`terminal timed out, buffer so far: ${buffer.slice(-400)}`)); }, 30000); ws.addEventListener('open', () => ws.send('kubectl get nodes\n')); ws.addEventListener('message', (ev) => { buffer += ev.data.toString(); if (/Ready\s/.test(buffer) || /node\.?\d*\s+Ready/i.test(buffer)) { clearTimeout(timer); ws.close(); resolve(buffer); } }); ws.addEventListener('error', (e) => { clearTimeout(timer); reject(new Error(`ws error: ${e.message || e}`)); }); }); } function expectWsRejected(launchPath, cookieHeader, timeoutMs = 5000) { return new Promise((resolve, reject) => { const ws = new WebSocket(`${WS_URL}${launchPath}shell-ws`, { headers: { Cookie: cookieHeader } }); const timer = setTimeout(() => { ws.close(); reject(new Error('expected the WS to be rejected, but it stayed open')); }, timeoutMs); ws.addEventListener('open', () => { clearTimeout(timer); ws.close(); reject(new Error('expected the WS upgrade to be rejected, but it succeeded')); }); ws.addEventListener('close', () => { clearTimeout(timer); resolve(); }); ws.addEventListener('error', () => { clearTimeout(timer); resolve(); }); }); } async function main() { console.log('Phase 4.1 two-host remote-routing simulation — slow, real k3s boots\n'); bootstrapOwner('sim-owner@rootnode.test', 'Sim Owner', 'SimOwnerPass!2026'); const admin = makeSession(GW); const { status: loginStatus } = await admin.request('POST', '/api/auth/instructor/login', { email: 'sim-owner@rootnode.test', password: 'SimOwnerPass!2026' }); assert.equal(loginStatus, 200, 'owner login must succeed through the Gateway'); let alice, bob; await test('roster: add Alice and Bob via admin (through the Gateway)', async () => { const a = await admin.request('POST', '/api/admin/students', { display_name: 'Sim-Alice', cohort_id: null }); const b = await admin.request('POST', '/api/admin/students', { display_name: 'Sim-Bob', cohort_id: null }); alice = a.body.student_id; bob = b.body.student_id; assert.notEqual(alice, bob); }); const aliceSession = makeSession(GW); const bobSession = makeSession(GW); await aliceSession.request('POST', '/api/login', { student_id: alice }); await bobSession.request('POST', '/api/login', { student_id: bob }); let aliceLaunch, bobLaunch, aliceEnv; await test('launch: Alice and Bob each get a running environment on the simulated remote lab host', async () => { const a = await aliceSession.request('POST', '/api/environment/launch'); const b = await bobSession.request('POST', '/api/environment/launch'); assert.equal(a.status, 200); assert.equal(a.body.status, 'running'); assert.equal(b.status, 200); assert.equal(b.body.status, 'running'); aliceLaunch = a.body; bobLaunch = b.body; const aliceToken = aliceLaunch.launch_path.split('/')[2]; aliceEnv = await internalAddressForToken(aliceToken); assert.equal(aliceEnv.lab_host_routing_mode, 'router', 'sim-lab-host-01 must be configured as routing_mode: router for this test to mean anything'); }); await test('§6/7 — the Gateway CANNOT reach the Lab\'s Docker-internal IP directly', async () => { const reachable = gatewayCanReach(aliceEnv.internal_ip, aliceEnv.internal_port); assert.equal(reachable, false, `gateway container reached ${aliceEnv.internal_ip}:${aliceEnv.internal_port} directly — the network isolation this whole phase depends on is not real`); }); await test('§6/7 — but the Gateway DOES reach the Lab through the Lab Host Router (HTTP)', async () => { const res = await aliceSession.rawFetch(aliceLaunch.launch_path); assert.equal(res.status, 200); const text = await res.text(); assert.ok(text.length > 0, 'expected real HTML/asset content back from the Lab, routed through the Router'); }); await test('§8 — Alice -> her own Lab: ALLOW', async () => { const res = await aliceSession.rawFetch(aliceLaunch.launch_path); assert.equal(res.status, 200); }); await test('§8 — Bob -> his own Lab: ALLOW', async () => { const res = await bobSession.rawFetch(bobLaunch.launch_path); assert.equal(res.status, 200); }); await test('§8 — Alice -> Bob\'s Lab: DENY', async () => { const res = await aliceSession.rawFetch(bobLaunch.launch_path); assert.equal(res.status, 403); }); await test('§8 — garbage token: DENY', async () => { const res = await aliceSession.rawFetch('/lab/not-a-real-token-at-all/'); assert.equal(res.status, 404); }); await test('§8 — anonymous Gateway request (no session at all): DENY', async () => { const res = await fetch(`${GW}${aliceLaunch.launch_path}`); assert.equal(res.status, 401); }); await test('§8/9 — direct public access to the Lab Host Router: NOT AVAILABLE (mTLS required, no client cert presented)', async () => { const routerUrl = new URL(aliceEnv.lab_host_router_endpoint); const result = await connectRouterWithoutClientCert(routerUrl.hostname, routerUrl.port); assert.equal(result.ok, false, 'the Router accepted a connection with no client certificate at all — mTLS is not actually being enforced'); }); await test('§7 — WebSocket terminal connects and a real `kubectl get nodes` succeeds, through the Router', async () => { const output = await openTerminal(bobLaunch.launch_path, `rn_session=${bobSession.getCookie('rn_session')}`); assert.ok(/Ready/i.test(output), `expected a Ready node in kubectl output, got: ${output.slice(-300)}`); }); await test('§8 — Alice cannot open Bob\'s terminal WebSocket even with a valid session (her own)', async () => { await expectWsRejected(bobLaunch.launch_path, `rn_session=${aliceSession.getCookie('rn_session')}`); }); await test('§8/9 — destroyed environment: routing token stops working, both at the Gateway and at the Router directly', async () => { const aliceToken = aliceLaunch.launch_path.split('/')[2]; await provisionerCall('DELETE', `/environments/${aliceEnv.environment_id}`); // Give the Gateway's short-TTL env-cache a moment to expire so this proves real // destroyed-environment handling, not just a stale cache hit. await new Promise((r) => setTimeout(r, 6000)); const res = await aliceSession.rawFetch(`/lab/${aliceToken}/`); assert.ok([404, 503].includes(res.status), `expected the destroyed environment to be rejected, got ${res.status}`); }); console.log(`\n${passed} passed, ${failed} failed`); if (failed > 0) process.exit(1); } main().catch((e) => { console.error(e); process.exit(1); });