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.
259 lines
12 KiB
JavaScript
259 lines
12 KiB
JavaScript
// RootNode Academy — Phase 1 Provisioner API.
|
|
// Internal-only (requirement #3/#8) — every route below requires X-Provisioner-Key. Nothing
|
|
// here is reachable by a student or the public internet in this phase; there's no frontend/
|
|
// Gateway wiring yet, so this is exercised directly (curl / the test suite in test/run.js).
|
|
//
|
|
// See docs/PHASE1_PLATFORM_FOUNDATION.md §4 for the approved route list this implements.
|
|
|
|
const express = require('express');
|
|
const { randomUUID } = require('crypto');
|
|
const { getPool, runMigrations } = require('./db');
|
|
const { applyLabHostsSeed, getResourcePolicy, reloadResourcePolicy } = require('./config');
|
|
const { healthCheck: dockerHealthCheck } = require('./docker');
|
|
const lifecycle = require('./lifecycle');
|
|
const { startReaper } = require('./reaper');
|
|
|
|
const PORT = Number(process.env.PROVISIONER_PORT) || 4100;
|
|
const SHARED_SECRET = process.env.PROVISIONER_SHARED_SECRET;
|
|
const LAB_IMAGE_REF = process.env.LAB_IMAGE_REF || 'rootnode-academy:test';
|
|
// No fixed EGRESS_PROXY_HOST: the proxy runs with host networking and is reached via each
|
|
// environment's own network gateway IP, resolved per-environment in lifecycle.js — a single
|
|
// static hostname wouldn't be reachable from every isolated student network. Only the port
|
|
// is fixed/shared.
|
|
const EGRESS_PROXY_PORT = Number(process.env.EGRESS_PROXY_PORT) || 3128;
|
|
const DNS_RESOLVER = process.env.DNS_RESOLVER || '127.0.0.11'; // Docker's embedded DNS by default
|
|
const NO_PROXY_CIDRS = (process.env.NO_PROXY_CIDRS || '10.42.0.0/16,10.43.0.0/16').split(',');
|
|
|
|
if (!SHARED_SECRET) {
|
|
console.error('FATAL: PROVISIONER_SHARED_SECRET is not set. Refusing to start with an unauthenticated internal API.');
|
|
process.exit(1);
|
|
}
|
|
|
|
function egressConfig() {
|
|
return {
|
|
proxyPort: EGRESS_PROXY_PORT,
|
|
dnsResolver: DNS_RESOLVER,
|
|
extraAllowedHosts: [], // none required in Phase 1 — the Lab image doesn't call back to the Platform yet
|
|
noProxyCidrs: NO_PROXY_CIDRS,
|
|
};
|
|
}
|
|
|
|
function resourcePolicyFor() {
|
|
const policy = getResourcePolicy().default;
|
|
// Phase 4.1 §26 — optional; null/unset (the default) means no cap is enforced, matching the
|
|
// Phase 4 rule against fabricating a concurrency number before it's measured. Set this from
|
|
// infra/scripts/capacity-model.js's real output once the host exists (see
|
|
// infra/environments/production/capacity.yml.example's hard-admission-control field) — see
|
|
// docs/PHASE4_1_PREPRODUCTION_HARDENING.md §26.
|
|
const admission = getResourcePolicy().admission_control || {};
|
|
return {
|
|
cpuLimit: policy.cpu_limit,
|
|
memLimitMb: policy.mem_limit_mb,
|
|
pidsLimit: policy.pids_limit,
|
|
storageLimitMb: policy.storage_limit_mb,
|
|
maxConcurrentLabs: admission.max_concurrent_labs || null,
|
|
};
|
|
}
|
|
|
|
function healthOpts() {
|
|
const hc = getResourcePolicy().health_check || {};
|
|
return { path: hc.path || '/api/health', timeoutMs: hc.timeout_ms || 120000 };
|
|
}
|
|
|
|
const app = express();
|
|
app.use(express.json());
|
|
|
|
// Every request gets a request id for structured error correlation, and every response is
|
|
// JSON — no HTML error pages, no leaking stack traces to a caller.
|
|
app.use((req, res, next) => {
|
|
req.requestId = randomUUID();
|
|
next();
|
|
});
|
|
|
|
function requireAuth(req, res, next) {
|
|
const key = req.get('X-Provisioner-Key');
|
|
if (!key || key !== SHARED_SECRET) {
|
|
return res.status(401).json({ error: 'unauthorized', request_id: req.requestId });
|
|
}
|
|
next();
|
|
}
|
|
|
|
function isValidStudentId(v) {
|
|
return typeof v === 'string' && v.length > 0 && v.length <= 128 && /^[a-zA-Z0-9_.-]+$/.test(v);
|
|
}
|
|
function isValidUUID(v) {
|
|
return typeof v === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v);
|
|
}
|
|
|
|
function asyncRoute(fn) {
|
|
return (req, res) => fn(req, res).catch((e) => {
|
|
console.error(`[${req.requestId}]`, e);
|
|
// CAPACITY_EXCEEDED (Phase 4.1 §26) is 503, not 500 — the host being full is an expected,
|
|
// recoverable operational state, not an application error. See lifecycle.js's admission
|
|
// check, which runs BEFORE any Docker/network resources are touched.
|
|
const status = e.code === 'NOT_FOUND' ? 404 : e.code === 'ALREADY_EXISTS' ? 409 : e.code === 'CAPACITY_EXCEEDED' ? 503 : 500;
|
|
res.status(status).json({ error: e.message, code: e.code || 'INTERNAL', request_id: req.requestId });
|
|
});
|
|
}
|
|
|
|
app.use(requireAuth);
|
|
|
|
// GET /health — Provisioner's own health: Docker reachable, Postgres reachable.
|
|
app.get('/health', asyncRoute(async (req, res) => {
|
|
const pool = getPool();
|
|
const dbOk = await pool.query('SELECT 1').then(() => true).catch(() => false);
|
|
const labHosts = await pool.query('SELECT * FROM lab_hosts WHERE enabled = true').catch(() => ({ rows: [] }));
|
|
const hostChecks = await Promise.all(labHosts.rows.map(async (h) => ({ lab_host_id: h.lab_host_id, ...(await dockerHealthCheck(h)) })));
|
|
const ok = dbOk && hostChecks.every((h) => h.ok);
|
|
res.status(ok ? 200 : 503).json({ ok, db: dbOk, lab_hosts: hostChecks });
|
|
}));
|
|
|
|
// GET /capacity — Phase 4.1 §26. Internal-only, same shared-secret auth as everything else
|
|
// here — lets the Platform API's instructor dashboard show real capacity state instead of
|
|
// instructors only discovering a full host when a student's launch fails.
|
|
app.get('/capacity', asyncRoute(async (req, res) => {
|
|
const { rows } = await getPool().query(`SELECT count(*)::int AS n FROM environments WHERE status NOT IN ('destroyed', 'error')`);
|
|
res.json({ current: rows[0].n, max: resourcePolicyFor().maxConcurrentLabs });
|
|
}));
|
|
|
|
// POST /environments — { student_id }
|
|
app.post('/environments', asyncRoute(async (req, res) => {
|
|
const { student_id } = req.body || {};
|
|
if (!isValidStudentId(student_id)) {
|
|
return res.status(400).json({ error: 'student_id is required and must be a simple identifier', request_id: req.requestId });
|
|
}
|
|
const env = await lifecycle.provisionEnvironment({
|
|
pool: getPool(),
|
|
studentId: student_id,
|
|
resourcePolicy: resourcePolicyFor(),
|
|
imageRef: LAB_IMAGE_REF,
|
|
egressConfig: egressConfig(),
|
|
health: healthOpts(),
|
|
});
|
|
res.status(201).json({ environment_id: env.environment_id, status: env.status, routing_token: env.routing_token });
|
|
}));
|
|
|
|
// GET /environments?student_id=
|
|
app.get('/environments', asyncRoute(async (req, res) => {
|
|
const { student_id } = req.query;
|
|
if (!isValidStudentId(student_id)) {
|
|
return res.status(400).json({ error: 'student_id query param is required', request_id: req.requestId });
|
|
}
|
|
const envs = await lifecycle.listEnvironments(getPool(), student_id);
|
|
res.json(envs.map(redactRow));
|
|
}));
|
|
|
|
// GET /environments/by-token/:token — Phase 2A addition, used by the Gateway to resolve a
|
|
// student's opaque routing token to the environment it addresses (internal_ip, status,
|
|
// owning student_id) before proxying. Internal-only, same shared-secret auth as every other
|
|
// route here — the Gateway is the only caller, browsers never reach this directly.
|
|
app.get('/environments/by-token/:token', asyncRoute(async (req, res) => {
|
|
// Phase 4.1: also joins lab_host_routing_mode/lab_host_router_endpoint so the Gateway can
|
|
// decide direct-vs-router without knowing anything about Docker topology itself — see
|
|
// lifecycle.getEnvironmentByTokenWithRouting and docs/PHASE4_1_PREPRODUCTION_HARDENING.md §1.
|
|
const env = await lifecycle.getEnvironmentByTokenWithRouting(getPool(), req.params.token);
|
|
if (!env) return res.status(404).json({ error: 'not found', request_id: req.requestId });
|
|
res.json(redactRow(env));
|
|
}));
|
|
|
|
// GET /environments/:id/token — Phase 2A addition. Every other read path deliberately redacts
|
|
// routing_token (see redactRow, and the "never log secrets in plaintext" requirement) — this
|
|
// is the one narrow exception, for the Gateway's own legitimate need to reconstruct a
|
|
// student's /lab/<token>/ launch URL for an environment that already exists (not just one it
|
|
// just created, where the token comes back un-redacted from POST /environments directly).
|
|
// Still shared-secret gated like every other route — nothing about this is student-reachable.
|
|
app.get('/environments/:id/token', asyncRoute(async (req, res) => {
|
|
if (!isValidUUID(req.params.id)) return res.status(400).json({ error: 'invalid environment id', request_id: req.requestId });
|
|
const env = await lifecycle.getEnvironment(getPool(), req.params.id);
|
|
if (!env) return res.status(404).json({ error: 'not found', request_id: req.requestId });
|
|
res.json({ environment_id: env.environment_id, routing_token: env.routing_token });
|
|
}));
|
|
|
|
// GET /environments/:id
|
|
app.get('/environments/:id', asyncRoute(async (req, res) => {
|
|
if (!isValidUUID(req.params.id)) return res.status(400).json({ error: 'invalid environment id', request_id: req.requestId });
|
|
const env = await lifecycle.getEnvironment(getPool(), req.params.id);
|
|
if (!env) return res.status(404).json({ error: 'not found', request_id: req.requestId });
|
|
res.json(redactRow(env));
|
|
}));
|
|
|
|
// POST /environments/:id/reset
|
|
app.post('/environments/:id/reset', asyncRoute(async (req, res) => {
|
|
if (!isValidUUID(req.params.id)) return res.status(400).json({ error: 'invalid environment id', request_id: req.requestId });
|
|
const env = await lifecycle.resetEnvironment({
|
|
pool: getPool(), environmentId: req.params.id,
|
|
resourcePolicy: resourcePolicyFor(), egressConfig: egressConfig(), health: healthOpts(),
|
|
actor: req.get('X-Actor') || 'system',
|
|
});
|
|
res.json(redactRow(env));
|
|
}));
|
|
|
|
// DELETE /environments/:id
|
|
app.delete('/environments/:id', asyncRoute(async (req, res) => {
|
|
if (!isValidUUID(req.params.id)) return res.status(400).json({ error: 'invalid environment id', request_id: req.requestId });
|
|
const env = await lifecycle.destroyEnvironment({ pool: getPool(), environmentId: req.params.id, actor: req.get('X-Actor') || 'system' });
|
|
res.json(redactRow(env));
|
|
}));
|
|
|
|
// POST /environments/:id/suspend — EXPERIMENTAL, see decision #3 / correction A.
|
|
app.post('/environments/:id/suspend', asyncRoute(async (req, res) => {
|
|
if (!isValidUUID(req.params.id)) return res.status(400).json({ error: 'invalid environment id', request_id: req.requestId });
|
|
const env = await lifecycle.suspendEnvironment({ pool: getPool(), environmentId: req.params.id, actor: req.get('X-Actor') || 'system' });
|
|
res.json({ ...redactRow(env), experimental: true });
|
|
}));
|
|
|
|
// POST /environments/:id/resume — EXPERIMENTAL, see decision #3 / correction A.
|
|
app.post('/environments/:id/resume', asyncRoute(async (req, res) => {
|
|
if (!isValidUUID(req.params.id)) return res.status(400).json({ error: 'invalid environment id', request_id: req.requestId });
|
|
const env = await lifecycle.resumeEnvironment({ pool: getPool(), environmentId: req.params.id, health: healthOpts(), actor: req.get('X-Actor') || 'system' });
|
|
res.json({ ...redactRow(env), experimental: true });
|
|
}));
|
|
|
|
// GET /environments/:id/events
|
|
app.get('/environments/:id/events', asyncRoute(async (req, res) => {
|
|
if (!isValidUUID(req.params.id)) return res.status(400).json({ error: 'invalid environment id', request_id: req.requestId });
|
|
const { rows } = await getPool().query(
|
|
'SELECT id, event_type, actor, detail, created_at FROM environment_events WHERE environment_id = $1 ORDER BY created_at ASC',
|
|
[req.params.id]
|
|
);
|
|
res.json(rows);
|
|
}));
|
|
|
|
function redactRow(env) {
|
|
const { routing_token, ...rest } = env;
|
|
return { ...rest, routing_token: routing_token ? '[redacted]' : null };
|
|
}
|
|
|
|
process.on('SIGHUP', () => {
|
|
console.log('SIGHUP received, reloading resource-policy.yaml');
|
|
try { reloadResourcePolicy(); } catch (e) { console.error('reload failed:', e.message); }
|
|
});
|
|
|
|
async function main() {
|
|
const pool = getPool();
|
|
const applied = await runMigrations();
|
|
if (applied.length) console.log('Applied migrations:', applied.join(', '));
|
|
const seeded = await applyLabHostsSeed(pool);
|
|
console.log(`Applied lab_hosts seed (${seeded} row(s))`);
|
|
|
|
const reconcileResult = await lifecycle.reconcileOnStartup(pool);
|
|
console.log(`Startup reconciliation: checked=${reconcileResult.checked} ok=${reconcileResult.ok} flagged=${reconcileResult.flagged}`);
|
|
|
|
const policy = getResourcePolicy();
|
|
const reaper = startReaper({
|
|
pool,
|
|
idleThresholdMinutes: policy.idle_threshold_minutes,
|
|
abandonedThresholdMinutes: policy.abandoned_threshold_minutes,
|
|
pollIntervalSeconds: policy.reaper_poll_interval_seconds,
|
|
});
|
|
process.on('SIGTERM', () => { reaper.stop(); process.exit(0); });
|
|
|
|
app.listen(PORT, () => console.log(`Provisioner API listening on :${PORT}`));
|
|
}
|
|
|
|
if (require.main === module) {
|
|
main().catch((e) => { console.error('FATAL:', e); process.exit(1); });
|
|
}
|
|
|
|
module.exports = { app, main };
|