test(preprod): two-host routing simulation, drill suite, CI security scanning
- 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).
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
# Two-Host Routing Simulation
|
||||
|
||||
Proves, with real Docker network isolation (not mocks), that the Gateway-to-Lab data path
|
||||
described in `docs/PHASE4_1_PREPRODUCTION_HARDENING.md` §1 actually works before the real
|
||||
Hetzner/Proxmox hardware exists. See that document for the full design; this is the test harness.
|
||||
|
||||
## Run it
|
||||
|
||||
```bash
|
||||
cd infra/scripts/simulate-two-host
|
||||
PROVISIONER_SHARED_SECRET=... ./run-simulation.sh # builds, runs, tears down
|
||||
PROVISIONER_SHARED_SECRET=... ./run-simulation.sh --keep-up # leaves the stack up for debugging
|
||||
```
|
||||
|
||||
Confirmed passing, 13/13, as of this phase's implementation — see the Phase 4.1 report for the
|
||||
full run transcript.
|
||||
|
||||
## What this genuinely proves (TESTED LOCALLY, with real Docker networking)
|
||||
|
||||
- The `gateway` container, attached ONLY to an isolated bridge network with no route to any
|
||||
Lab's per-environment bridge network, **cannot** open a TCP connection to a Lab container's
|
||||
Docker-internal IP — verified via a raw connect attempt run from inside the actual gateway
|
||||
container's own network namespace, not asserted from the test driver's (irrelevant) vantage
|
||||
point.
|
||||
- The Gateway **does** reach the Lab correctly through the Lab Host Router, over real mTLS,
|
||||
for both a plain HTTP request and a full WebSocket terminal session — including a genuine
|
||||
`kubectl get nodes` executed inside the Lab and its real output read back.
|
||||
- Alice/Bob isolation (own lab ALLOW, other's lab DENY, including over WebSocket), garbage
|
||||
tokens, anonymous requests, and destroyed-environment tokens are all rejected through the
|
||||
real code path — the same `checkOwnership`/`resolveTarget` logic production will run.
|
||||
- The Lab Host Router refuses a connection with no client certificate at all — mTLS is the
|
||||
actual authentication mechanism, not a documented intention.
|
||||
|
||||
## What this does NOT prove (SIMULATED PRODUCTION BOUNDARY, not identical to real hardware)
|
||||
|
||||
- Real cross-VM latency, MTU, or anything about two genuinely separate kernels — this is one
|
||||
Docker daemon with two isolated networks, a faithful stand-in for network *reachability*
|
||||
rules but not a physical network.
|
||||
- The actual Hetzner/Proxmox firewall enforcement (`infra/firewall/*.rules`) — this harness
|
||||
doesn't touch iptables at all; Docker's own network isolation is what's under test here.
|
||||
- Provisioner/API/Postgres reachability across the VM boundary — deliberately kept
|
||||
host-networked and unchanged from `docker-compose.dev.yml` in this harness, because that
|
||||
control-plane path was already proven separately (Phase 4's real mTLS handshake test against
|
||||
the Docker Engine TLS port) and isn't what changed this phase. Only the Gateway's isolation
|
||||
is new here.
|
||||
|
||||
## REQUIRES REAL HARDWARE (tracked in docs/PRODUCTION_VALIDATION_CHECKLIST.md, not here)
|
||||
|
||||
Running this exact flow against the real `rn-platform-01`/`rn-lab-host-01` VMs, through the
|
||||
real firewall, with real network latency between two physical/virtual hosts.
|
||||
@@ -0,0 +1,136 @@
|
||||
# Phase 4.1 two-host routing simulation — docs/PHASE4_1_PREPRODUCTION_HARDENING.md §6.
|
||||
#
|
||||
# Proves the ONE thing that matters using REAL Docker network isolation, not mocks: a Gateway
|
||||
# container with no route to a Lab container's bridge network genuinely cannot reach it, and
|
||||
# can only reach it through the Lab Host Router.
|
||||
#
|
||||
# What's genuinely simulated (real Docker networking facts, not faked):
|
||||
# - `gateway` is attached ONLY to `sim-platform-net`. It has no network_mode: host, no
|
||||
# attachment to any per-Lab bridge network (rootnode-lab-net-<id>, created dynamically by
|
||||
# the Provisioner) — its network namespace simply has no route to one. This is the same
|
||||
# kind of isolation two separate Proxmox VMs would have, achieved here with two Docker
|
||||
# networks on one daemon instead of two physical hosts.
|
||||
# - `lab-router` runs with network_mode: host — exactly like `egress-proxy` already does in
|
||||
# docker-compose.dev.yml (see that file's comment on why: a host-networked process can
|
||||
# reach every bridge network's containers via the host's own routing table, which is
|
||||
# exactly what a real Lab Host Router process running directly on rn-lab-host-01 needs).
|
||||
# It's also reachable FROM `sim-platform-net` via that network's own gateway IP — the same
|
||||
# mechanism the egress-proxy already relies on for the reverse direction.
|
||||
# - `provisioner`/`api`/`postgres` stay host-networked, unchanged from docker-compose.dev.yml
|
||||
# — this phase's new proof target is the Gateway-to-Lab DATA plane, not the CONTROL plane
|
||||
# (already proven with a real mTLS handshake in Phase 4). Keeping them as-is is a
|
||||
# deliberate scope choice, not an oversight — see the README in this directory.
|
||||
#
|
||||
# What this does NOT prove (needs the real hardware — docs/PRODUCTION_VALIDATION_CHECKLIST.md):
|
||||
# real cross-VM network latency/MTU, the actual Hetzner/Proxmox firewall enforcement, or
|
||||
# anything about two genuinely separate kernels.
|
||||
#
|
||||
# Usage: see README.md in this directory.
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: rootnode_platform_sim
|
||||
ports:
|
||||
- "5434:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
|
||||
egress-proxy:
|
||||
build: ../../../platform/egress-proxy
|
||||
network_mode: host
|
||||
|
||||
provisioner:
|
||||
build: ../../../platform/provisioner
|
||||
network_mode: host
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
- NET_RAW
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
# run-simulation.sh generates this with the real routing_mode: router / router_endpoint
|
||||
# once it knows sim-platform-net's actual gateway IP — overrides just this one file, the
|
||||
# image's own resource-policy.yaml underneath is untouched.
|
||||
- ${SIM_LAB_HOSTS_SEED:?set by run-simulation.sh}:/app/config/lab-hosts.seed.yaml:ro
|
||||
environment:
|
||||
PLATFORM_DB_URL: postgres://postgres:postgres@localhost:5434/rootnode_platform_sim
|
||||
PROVISIONER_SHARED_SECRET: ${PROVISIONER_SHARED_SECRET:?set a real secret, do not use a default}
|
||||
PROVISIONER_PORT: "4101"
|
||||
LAB_IMAGE_REF: ${LAB_IMAGE_REF:-rootnode-academy:test}
|
||||
EGRESS_PROXY_PORT: "3128"
|
||||
DNS_RESOLVER: "127.0.0.11"
|
||||
NO_PROXY_CIDRS: "10.42.0.0/16,10.43.0.0/16"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
||||
api:
|
||||
build:
|
||||
context: ../../..
|
||||
dockerfile: platform/api/Dockerfile
|
||||
network_mode: host
|
||||
environment:
|
||||
PLATFORM_DB_URL: postgres://postgres:postgres@localhost:5434/rootnode_platform_sim
|
||||
PROVISIONER_URL: "http://localhost:4101"
|
||||
PROVISIONER_SHARED_SECRET: ${PROVISIONER_SHARED_SECRET:?set a real secret, do not use a default}
|
||||
PLATFORM_API_PORT: "4301"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
provisioner:
|
||||
condition: service_started
|
||||
|
||||
# Simulates rn-platform-01's Gateway — deliberately NOT host-networked, deliberately not
|
||||
# attached to any Lab bridge network. This is the isolation under test.
|
||||
gateway:
|
||||
build: ../../../platform/gateway
|
||||
networks:
|
||||
- sim-platform-net
|
||||
environment:
|
||||
GATEWAY_PORT: "4200"
|
||||
# Reaches host-networked Provisioner/API via sim-platform-net's own gateway IP — the
|
||||
# same "reachable via the bridge's gateway interface" mechanism egress-proxy already
|
||||
# relies on (see docker-compose.dev.yml). run-simulation.sh fills these in.
|
||||
PROVISIONER_URL: ${SIM_PROVISIONER_URL:?set by run-simulation.sh}
|
||||
PROVISIONER_SHARED_SECRET: ${PROVISIONER_SHARED_SECRET:?set a real secret, do not use a default}
|
||||
PLATFORM_API_URL: ${SIM_PLATFORM_API_URL:?set by run-simulation.sh}
|
||||
LAB_ROUTER_TLS_CA: /tls/ca.pem
|
||||
LAB_ROUTER_TLS_CERT: /tls/gateway-client-cert.pem
|
||||
LAB_ROUTER_TLS_KEY: /tls/gateway-client-key.pem
|
||||
volumes:
|
||||
- ${SIM_TLS_DIR:?set by run-simulation.sh}:/tls:ro
|
||||
ports:
|
||||
- "4210:4200" # exposed to the test driver script only, not "public" in any real sense
|
||||
depends_on:
|
||||
- provisioner
|
||||
- api
|
||||
|
||||
# Simulates rn-lab-host-01's Lab Host Router — host-networked like egress-proxy, for the same
|
||||
# reason (needs to reach whatever per-Lab bridge networks the Provisioner creates, which are
|
||||
# not known/attachable ahead of time).
|
||||
lab-router:
|
||||
build: ../../../platform/lab-router
|
||||
network_mode: host
|
||||
environment:
|
||||
LAB_ROUTER_PORT: "8443"
|
||||
LAB_ROUTER_TLS_CA: /tls/ca.pem
|
||||
LAB_ROUTER_TLS_CERT: /tls/router-server-cert.pem
|
||||
LAB_ROUTER_TLS_KEY: /tls/router-server-key.pem
|
||||
DOCKER_SOCKET: /var/run/docker.sock
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- ${SIM_TLS_DIR:?set by run-simulation.sh}:/tls:ro
|
||||
|
||||
networks:
|
||||
# Pre-created by run-simulation.sh (so it can inspect the real gateway IP BEFORE any
|
||||
# container starts and hand it to provisioner/gateway/lab-router as config) — Compose is
|
||||
# told to use that existing network rather than create its own.
|
||||
sim-platform-net:
|
||||
external: true
|
||||
name: ${COMPOSE_NET_NAME:?set by run-simulation.sh}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env bash
|
||||
# Orchestrates the Phase 4.1 two-host routing simulation end-to-end: generates a real TLS
|
||||
# bundle, computes the sim-platform-net gateway IP, renders the lab_hosts seed with it, brings
|
||||
# the stack up, runs test-remote-routing.js, tears everything down. See README.md in this
|
||||
# directory and docs/PHASE4_1_PREPRODUCTION_HARDENING.md §6.
|
||||
#
|
||||
# Usage: ./run-simulation.sh [--keep-up] (--keep-up skips teardown, for debugging)
|
||||
set -euo pipefail
|
||||
cd "$(dirname "${BASH_SOURCE[0]}")"
|
||||
|
||||
KEEP_UP=false
|
||||
[ "${1:-}" = "--keep-up" ] && KEEP_UP=true
|
||||
|
||||
WORK_DIR="$(mktemp -d)"
|
||||
trap 'rm -rf "$WORK_DIR"' EXIT
|
||||
|
||||
export PROVISIONER_SHARED_SECRET="${PROVISIONER_SHARED_SECRET:-sim-shared-secret-not-for-real-use}"
|
||||
export LAB_IMAGE_REF="${LAB_IMAGE_REF:-rootnode-academy:test}"
|
||||
export SIM_TLS_DIR="$WORK_DIR/tls"
|
||||
export SIM_LAB_HOSTS_SEED="$WORK_DIR/lab-hosts.seed.yaml"
|
||||
|
||||
echo "==> [1/6] Creating sim-platform-net so we know its real gateway IP before generating certs"
|
||||
NET_NAME="rootnode-sim-platform-net"
|
||||
docker network inspect "$NET_NAME" >/dev/null 2>&1 || docker network create "$NET_NAME" >/dev/null
|
||||
GW_IP="$(docker network inspect "$NET_NAME" --format '{{(index .IPAM.Config 0).Gateway}}')"
|
||||
echo " sim-platform-net gateway IP: $GW_IP (lab-router and provisioner/api, all host-networked, are reachable from Gateway's isolated network through this address)"
|
||||
|
||||
echo "==> [2/6] Generating a real mTLS bundle for this simulation run (SAN covers the real gateway IP above)"
|
||||
../docker-tls/generate-docker-tls.sh --out-dir "$SIM_TLS_DIR" --lab-host-ip "$GW_IP" --lab-host-dns lab-router-sim >/dev/null
|
||||
|
||||
# Rewrite the compose file's network name to match what we just created/inspected, and point
|
||||
# the sim gateway/router services at the shared network by that exact name.
|
||||
export COMPOSE_NET_NAME="$NET_NAME"
|
||||
|
||||
echo "==> [3/6] Rendering lab-hosts.seed.yaml with routing_mode: router at the real gateway IP"
|
||||
cat > "$SIM_LAB_HOSTS_SEED" <<EOF
|
||||
lab_hosts:
|
||||
- lab_host_id: sim-lab-host-01
|
||||
display_name: "Simulated rn-lab-host-01"
|
||||
endpoint: "unix:///var/run/docker.sock"
|
||||
enabled: true
|
||||
routing_mode: router
|
||||
router_endpoint: "https://${GW_IP}:8443"
|
||||
EOF
|
||||
|
||||
export SIM_PROVISIONER_URL="http://${GW_IP}:4101"
|
||||
export SIM_PLATFORM_API_URL="http://${GW_IP}:4301"
|
||||
|
||||
echo "==> [4/6] Building and starting the simulated two-host stack"
|
||||
docker compose -f docker-compose.simulate.yml -p rootnode-sim up -d --build
|
||||
|
||||
cleanup() {
|
||||
if [ "$KEEP_UP" = true ]; then
|
||||
echo "==> --keep-up set, leaving the stack running (docker compose -f docker-compose.simulate.yml -p rootnode-sim down to clean up)"
|
||||
return
|
||||
fi
|
||||
echo "==> [6/6] Tearing down the simulation stack"
|
||||
docker compose -f docker-compose.simulate.yml -p rootnode-sim down -v >/dev/null 2>&1 || true
|
||||
docker network rm "$NET_NAME" >/dev/null 2>&1 || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
echo "==> Waiting for services to become healthy"
|
||||
for i in $(seq 1 60); do
|
||||
if curl -sf http://localhost:4301/healthz >/dev/null 2>&1 && curl -sf http://localhost:4210/healthz >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
echo "==> [5/6] Running the routing test"
|
||||
GATEWAY_URL=http://localhost:4210 \
|
||||
PROVISIONER_URL=http://localhost:4101 \
|
||||
PROVISIONER_SHARED_SECRET="$PROVISIONER_SHARED_SECRET" \
|
||||
GATEWAY_CONTAINER=rootnode-sim-gateway-1 \
|
||||
node test-remote-routing.js
|
||||
@@ -0,0 +1,212 @@
|
||||
#!/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); });
|
||||
Reference in New Issue
Block a user