- infra/scripts/simulate-two-host/: real two-Docker-network simulation (Gateway genuinely isolated from any Lab bridge network) proving the Lab Host Router fix - 13/13 real assertions (HTTP+WebSocket through the Router, Alice/Bob isolation, garbage/destroyed tokens, mTLS-only Router access). - infra/scripts/drills/: Lab Host loss/rebuild (9/9), deployment rollback, CI-fails-closed (6 required failure modes), fake cohort dress rehearsal (14/14, one continuous narrative through the real app). - infra/ci/gitea-workflows/build-and-test.yml: new security-scan job (npm audit, gitleaks, hadolint, shellcheck, trivy - all free/open-source), severity policy in docs/SECURITY_SCANNING.md (CRITICAL/HIGH blocks, MEDIUM/LOW reported only). Gates build-images alongside regression. Found and fixed two real pre-existing bugs via the CI-fails-closed drill: staging-smoke-test.sh and production-smoke-test.sh both had a classic set -e gotcha (curl ... && echo "OK" as one statement - a failing command that isn't last in an && chain doesn't trigger -e) that let an unreachable/broken endpoint fall through silently instead of failing the smoke test. platform/test/e2e-phase3a.js updated for the new paginated /api/admin/audit-events response shape (Phase 4.1 pagination work).
213 lines
11 KiB
JavaScript
213 lines
11 KiB
JavaScript
#!/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('[email protected]', 'Sim Owner', 'SimOwnerPass!2026');
|
|
const admin = makeSession(GW);
|
|
const { status: loginStatus } = await admin.request('POST', '/api/auth/instructor/login', { email: '[email protected]', 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); });
|