feat(routing): Lab Host Router closes the Gateway-to-Lab cross-VM gap
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.
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
// Phase 4.1 — the ONE place the Gateway decides how to reach a student's Lab. Everything else
|
||||
// in server.js just calls labTarget(env, token) and gets back a plain { url, headers, agent }
|
||||
// it can hand to http-proxy or fetch, with zero knowledge of Docker, VMs, or lab_hosts. See
|
||||
// docs/PHASE4_1_PREPRODUCTION_HARDENING.md §1 and docs/PRODUCTION_NETWORK_SECURITY.md.
|
||||
//
|
||||
// env.lab_host_routing_mode / env.lab_host_router_endpoint come from the Provisioner's
|
||||
// by-token response (a join against lab_hosts — see provisioner lifecycle.js
|
||||
// getEnvironmentByTokenWithRouting). Two modes:
|
||||
//
|
||||
// 'direct' (default, dev/single-host) — unchanged from every prior phase: the Gateway
|
||||
// reaches env.internal_ip:internal_port itself, because Gateway and the Lab share a Docker
|
||||
// daemon/network namespace.
|
||||
//
|
||||
// 'router' (production, two real VMs) — the Gateway never touches a Docker-internal
|
||||
// address. It reaches that lab_host's Lab Host Router (platform/lab-router) instead, over
|
||||
// mTLS, carrying the environment's routing_token in a header so the Router — which lives on
|
||||
// the SAME host as the Lab and can resolve the token from local `docker ps` alone — knows
|
||||
// which local container to proxy to.
|
||||
|
||||
const fs = require('fs');
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
const { URL } = require('url');
|
||||
|
||||
const ROUTE_TOKEN_HEADER = 'x-rootnode-route-token';
|
||||
|
||||
// mTLS client identity the Gateway presents to a Lab Host Router. One shared cert/key/ca is
|
||||
// enough for Phase 4.1's single lab_host; if a second lab_host ever needs a distinct client
|
||||
// identity, this becomes a map keyed by lab_host_id — not needed yet (see docs
|
||||
// "multi-host-ready" note, no scheduler built here).
|
||||
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;
|
||||
|
||||
let _agent = null;
|
||||
function routerAgent() {
|
||||
if (_agent) return _agent;
|
||||
if (!TLS_CA || !TLS_CERT || !TLS_KEY) {
|
||||
throw new Error(
|
||||
'A lab_host is configured with routing_mode=router but LAB_ROUTER_TLS_CA/CERT/KEY are not set — refusing to connect to the Lab Host Router unauthenticated.'
|
||||
);
|
||||
}
|
||||
_agent = new https.Agent({
|
||||
ca: fs.readFileSync(TLS_CA),
|
||||
cert: fs.readFileSync(TLS_CERT),
|
||||
key: fs.readFileSync(TLS_KEY),
|
||||
// The Router's server cert is issued by the same private CA (infra/scripts/docker-tls/
|
||||
// generate-docker-tls.sh) — never fall back to the public CA trust store for this.
|
||||
});
|
||||
return _agent;
|
||||
}
|
||||
|
||||
// env: the (possibly-cached) environment row from the Provisioner's by-token response.
|
||||
// token: the routing token the browser's URL already carried (Gateway never needs the
|
||||
// Provisioner to hand it back — see provisioner server.js redactRow's comment on why
|
||||
// routing_token is redacted everywhere except the two routes that legitimately need it,
|
||||
// neither of which is this one).
|
||||
function labTarget(env, token) {
|
||||
if (env.lab_host_routing_mode === 'router') {
|
||||
if (!env.lab_host_router_endpoint) {
|
||||
throw new Error(`lab_host '${env.lab_host_id}' has routing_mode=router but no router_endpoint`);
|
||||
}
|
||||
return {
|
||||
url: env.lab_host_router_endpoint,
|
||||
headers: { [ROUTE_TOKEN_HEADER]: token },
|
||||
agent: routerAgent(),
|
||||
mode: 'router',
|
||||
// The browser's original Host header (e.g. "localhost:4210" or the real public origin)
|
||||
// has nothing to do with the Router's own TLS identity — without this, http-proxy's
|
||||
// outgoing HTTPS connection inherits that unrelated Host header for SNI/hostname
|
||||
// verification and the Router's cert (correctly issued for ITS OWN name) fails to
|
||||
// match. changeOrigin makes http-proxy rewrite the outgoing Host header (and therefore
|
||||
// the TLS servername) to match the actual target — found by testing against the real
|
||||
// Phase 4.1 simulation harness, not theoretical.
|
||||
changeOrigin: true,
|
||||
};
|
||||
}
|
||||
// direct — unchanged behavior from every phase before this one.
|
||||
return { url: `http://${env.internal_ip}:${env.internal_port}`, headers: {}, agent: undefined, mode: 'direct', changeOrigin: false };
|
||||
}
|
||||
|
||||
// Used by the two Gateway call sites that need to make a one-shot request to the Lab and read
|
||||
// its JSON body (progress/answer/validate relay, hint-count stripping) — everywhere else
|
||||
// (the actual proxy paths) uses http-proxy directly with the same target/agent/headers.
|
||||
//
|
||||
// Deliberately built on node:http/node:https rather than global fetch: fetch's undici
|
||||
// dispatcher isn't a stable, dependency-free way to attach a custom mTLS agent across Node
|
||||
// versions, and http.request/https.request support client certs natively with zero new
|
||||
// dependencies (stdlib covers it — see the ladder in CLAUDE.md's coding-style guidance).
|
||||
// Mimics just enough of fetch's response shape ({ status, json() }) that call sites barely
|
||||
// change.
|
||||
function labRequest(env, token, path, { method = 'GET', headers = {}, body } = {}) {
|
||||
const target = labTarget(env, token);
|
||||
const url = new URL(path, target.url);
|
||||
const transport = url.protocol === 'https:' ? https : http;
|
||||
const payload = body === undefined ? undefined : (typeof body === 'string' ? body : JSON.stringify(body));
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = transport.request(
|
||||
url,
|
||||
{
|
||||
method,
|
||||
agent: target.agent,
|
||||
headers: {
|
||||
...headers,
|
||||
...target.headers,
|
||||
...(payload !== undefined ? { 'Content-Length': Buffer.byteLength(payload) } : {}),
|
||||
},
|
||||
},
|
||||
(res) => {
|
||||
const chunks = [];
|
||||
res.on('data', (c) => chunks.push(c));
|
||||
res.on('end', () => {
|
||||
const text = Buffer.concat(chunks).toString('utf8');
|
||||
resolve({
|
||||
status: res.statusCode,
|
||||
json: async () => JSON.parse(text),
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
req.on('error', reject);
|
||||
if (payload !== undefined) req.write(payload);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { labTarget, labRequest, ROUTE_TOKEN_HEADER };
|
||||
@@ -19,6 +19,11 @@ const express = require('express');
|
||||
const httpProxy = require('http-proxy');
|
||||
const { provisioner } = require('./provisioner-client'); // still used ONLY for the token->environment lookup a Lab request needs; all mutating calls (provision/reset/destroy) now live in the Platform API.
|
||||
const envCache = require('./env-cache');
|
||||
// Phase 4.1 — the ONE place that knows how to reach a Lab (direct Docker-internal address, or
|
||||
// via that lab_host's Lab Host Router). Every proxy.web/proxy.ws/labRequest call below uses
|
||||
// labTarget's output instead of building `http://${env.internal_ip}:${env.internal_port}`
|
||||
// itself. See lab-endpoint.js's header comment and docs/PHASE4_1_PREPRODUCTION_HARDENING.md §1.
|
||||
const { labTarget, labRequest } = require('./lab-endpoint');
|
||||
|
||||
const PORT = Number(process.env.GATEWAY_PORT) || 4200;
|
||||
const PLATFORM_API_URL = process.env.PLATFORM_API_URL || 'http://localhost:4300';
|
||||
@@ -90,6 +95,9 @@ async function resolveByToken(token) {
|
||||
return body;
|
||||
}
|
||||
|
||||
// internal_ip is set by the Provisioner as soon as a container exists, regardless of routing
|
||||
// mode (it's a Docker-internal fact, independent of how the Gateway will reach it) — so this
|
||||
// check stays valid unchanged for both 'direct' and 'router' lab_hosts.
|
||||
function isReachable(env) {
|
||||
return env && env.status === 'running' && env.internal_ip;
|
||||
}
|
||||
@@ -116,9 +124,9 @@ async function checkOwnership(req, env) {
|
||||
|
||||
// ── Progress relay (unchanged mechanism from Phase 2A, now passes environment_id +
|
||||
// validation_count through so Platform API can log a real scenario_attempts row) ──────────
|
||||
async function relayScenarioOutcome({ env, req, res, scenarioPath, extractOutcome }) {
|
||||
async function relayScenarioOutcome({ env, token, req, res, scenarioPath, extractOutcome }) {
|
||||
if (!isReachable(env)) return sendLabError(res, 503, 'lab_unavailable', 'Your lab is not currently running.');
|
||||
const labRes = await fetch(`http://${env.internal_ip}:${env.internal_port}${scenarioPath}`, {
|
||||
const labRes = await labRequest(env, token, scenarioPath, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(req.body || {}),
|
||||
@@ -157,7 +165,7 @@ app.post('/lab/:token/api/scenarios/:scenarioId/validate', express.json(), async
|
||||
const own = await checkOwnership(req, env);
|
||||
if (!own.ok) return sendLabError(res, own.reason === 'invalid_token' ? 404 : 403, own.reason, 'This lab is not available to you.');
|
||||
await relayScenarioOutcome({
|
||||
env, req, res, scenarioPath: `/api/scenarios/${req.params.scenarioId}/validate`,
|
||||
env, token: req.params.token, req, res, scenarioPath: `/api/scenarios/${req.params.scenarioId}/validate`,
|
||||
extractOutcome: (body) => (typeof body.passed === 'boolean' ? body.passed : null),
|
||||
});
|
||||
});
|
||||
@@ -167,7 +175,7 @@ app.post('/lab/:token/api/scenarios/:scenarioId/answer', express.json(), async (
|
||||
const own = await checkOwnership(req, env);
|
||||
if (!own.ok) return sendLabError(res, own.reason === 'invalid_token' ? 404 : 403, own.reason, 'This lab is not available to you.');
|
||||
await relayScenarioOutcome({
|
||||
env, req, res, scenarioPath: `/api/scenarios/${req.params.scenarioId}/answer`,
|
||||
env, token: req.params.token, req, res, scenarioPath: `/api/scenarios/${req.params.scenarioId}/answer`,
|
||||
extractOutcome: (body) => (typeof body.correct === 'boolean' ? body.correct : null),
|
||||
});
|
||||
});
|
||||
@@ -223,7 +231,7 @@ app.get('/lab/:token/api/scenarios/:scenarioId', async (req, res) => {
|
||||
if (!own.ok) return sendLabError(res, own.reason === 'invalid_token' ? 404 : 403, own.reason, 'This lab is not available to you.');
|
||||
if (!isReachable(env)) return sendLabError(res, 503, 'lab_unavailable', 'Your lab is not currently running.');
|
||||
try {
|
||||
const labRes = await fetch(`http://${env.internal_ip}:${env.internal_port}/api/scenarios/${req.params.scenarioId}`);
|
||||
const labRes = await labRequest(env, req.params.token, `/api/scenarios/${req.params.scenarioId}`);
|
||||
const body = await labRes.json().catch(() => ({}));
|
||||
const { hints, ...withoutHints } = body;
|
||||
res.status(labRes.status).json({ ...withoutHints, hint_count: Array.isArray(hints) ? hints.length : 0 });
|
||||
@@ -259,7 +267,8 @@ app.use('/lab/:token', async (req, res, next) => {
|
||||
// for this browser.
|
||||
res.append('Set-Cookie', `${LAB_ACTIVE_COOKIE}=1; Path=/; HttpOnly; SameSite=Lax; Max-Age=28800`);
|
||||
req.url = req.url.replace(/^\/lab\/[^/]+/, '') || '/';
|
||||
proxy.web(req, res, { target: `http://${env.internal_ip}:${env.internal_port}` });
|
||||
const target = labTarget(env, token);
|
||||
proxy.web(req, res, { target: target.url, agent: target.agent, headers: target.headers, changeOrigin: target.changeOrigin });
|
||||
});
|
||||
|
||||
// Resolves "the environment this browser's session currently owns and can reach," for the
|
||||
@@ -268,20 +277,23 @@ app.use('/lab/:token', async (req, res, next) => {
|
||||
// the WS upgrade handler so there's exactly one place this logic lives. Gated on
|
||||
// LAB_ACTIVE_COOKIE first — see its definition above for why "has a running env" alone isn't
|
||||
// a safe signal (it's true on the dashboard too).
|
||||
// Returns { env, token } (not just env) — Phase 4.1's labTarget needs the routing token
|
||||
// alongside the environment row for router-mode lab_hosts, and this is the only place that
|
||||
// derives the token from launch_path rather than the URL, so it has to hand both back.
|
||||
async function resolveOwnedRunningEnvironment(req) {
|
||||
const cookies = parseCookies(req);
|
||||
if (!cookies[LAB_ACTIVE_COOKIE]) return null;
|
||||
const token = cookies[STUDENT_COOKIE];
|
||||
if (!token) return null;
|
||||
const sessionToken = cookies[STUDENT_COOKIE];
|
||||
if (!sessionToken) return null;
|
||||
const studentId = await resolveSessionStudentId(req);
|
||||
if (!studentId) return null;
|
||||
try {
|
||||
const envRes = await fetch(`${PLATFORM_API_URL}/api/environment/status`, { headers: { Cookie: `${STUDENT_COOKIE}=${token}` } });
|
||||
const envRes = await fetch(`${PLATFORM_API_URL}/api/environment/status`, { headers: { Cookie: `${STUDENT_COOKIE}=${sessionToken}` } });
|
||||
const envInfo = await envRes.json().catch(() => null);
|
||||
if (envInfo?.status !== 'running' || !envInfo.launch_path) return null;
|
||||
const lookupToken = envInfo.launch_path.split('/')[2];
|
||||
const env = await resolveByToken(lookupToken);
|
||||
return isReachable(env) && env.student_id === studentId ? env : null;
|
||||
return isReachable(env) && env.student_id === studentId ? { env, token: lookupToken } : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -303,8 +315,11 @@ function looksLikeLabPath(pathname) {
|
||||
// or the Platform API (student dashboard, admin portal, roster/curriculum/progress) ────────
|
||||
app.use(async (req, res) => {
|
||||
if (looksLikeLabPath(req.path)) {
|
||||
const env = await resolveOwnedRunningEnvironment(req);
|
||||
if (env) return proxy.web(req, res, { target: `http://${env.internal_ip}:${env.internal_port}` });
|
||||
const owned = await resolveOwnedRunningEnvironment(req);
|
||||
if (owned) {
|
||||
const target = labTarget(owned.env, owned.token);
|
||||
return proxy.web(req, res, { target: target.url, agent: target.agent, headers: target.headers, changeOrigin: target.changeOrigin });
|
||||
}
|
||||
}
|
||||
proxy.web(req, res, { target: PLATFORM_API_URL });
|
||||
});
|
||||
@@ -319,12 +334,16 @@ server.on('upgrade', async (req, socket, head) => {
|
||||
const own = await checkOwnership(req, env);
|
||||
if (!own.ok || !isReachable(env)) return socket.destroy();
|
||||
req.url = rest || '/';
|
||||
return proxy.ws(req, socket, head, { target: `http://${env.internal_ip}:${env.internal_port}` });
|
||||
const target = labTarget(env, token);
|
||||
return proxy.ws(req, socket, head, { target: target.url, agent: target.agent, headers: target.headers, changeOrigin: target.changeOrigin });
|
||||
}
|
||||
|
||||
// Unprefixed WS (the existing Lab UI's /shell-ws, hit while "inside" a lab session).
|
||||
const env = await resolveOwnedRunningEnvironment(req);
|
||||
if (env) return proxy.ws(req, socket, head, { target: `http://${env.internal_ip}:${env.internal_port}` });
|
||||
const owned = await resolveOwnedRunningEnvironment(req);
|
||||
if (owned) {
|
||||
const target = labTarget(owned.env, owned.token);
|
||||
return proxy.ws(req, socket, head, { target: target.url, agent: target.agent, headers: target.headers, changeOrigin: target.changeOrigin });
|
||||
}
|
||||
socket.destroy();
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
FROM node:20-slim
|
||||
WORKDIR /app
|
||||
COPY package.json ./
|
||||
RUN npm install --production
|
||||
COPY src/ ./src/
|
||||
EXPOSE 8443
|
||||
CMD ["node", "src/server.js"]
|
||||
Generated
+706
@@ -0,0 +1,706 @@
|
||||
{
|
||||
"name": "rootnode-lab-router",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "rootnode-lab-router",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"dockerode": "^4.0.2",
|
||||
"http-proxy": "^1.18.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@balena/dockerignore": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@balena/dockerignore/-/dockerignore-1.0.2.tgz",
|
||||
"integrity": "sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q=="
|
||||
},
|
||||
"node_modules/@grpc/grpc-js": {
|
||||
"version": "1.14.4",
|
||||
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.4.tgz",
|
||||
"integrity": "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==",
|
||||
"dependencies": {
|
||||
"@grpc/proto-loader": "^0.8.0",
|
||||
"@js-sdsl/ordered-map": "^4.4.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@grpc/grpc-js/node_modules/@grpc/proto-loader": {
|
||||
"version": "0.8.1",
|
||||
"resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.1.tgz",
|
||||
"integrity": "sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg==",
|
||||
"dependencies": {
|
||||
"lodash.camelcase": "^4.3.0",
|
||||
"long": "^5.0.0",
|
||||
"protobufjs": "^7.5.5",
|
||||
"yargs": "^17.7.2"
|
||||
},
|
||||
"bin": {
|
||||
"proto-loader-gen-types": "build/bin/proto-loader-gen-types.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/@grpc/proto-loader": {
|
||||
"version": "0.7.15",
|
||||
"resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz",
|
||||
"integrity": "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==",
|
||||
"dependencies": {
|
||||
"lodash.camelcase": "^4.3.0",
|
||||
"long": "^5.0.0",
|
||||
"protobufjs": "^7.2.5",
|
||||
"yargs": "^17.7.2"
|
||||
},
|
||||
"bin": {
|
||||
"proto-loader-gen-types": "build/bin/proto-loader-gen-types.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/@js-sdsl/ordered-map": {
|
||||
"version": "4.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz",
|
||||
"integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/js-sdsl"
|
||||
}
|
||||
},
|
||||
"node_modules/@protobufjs/aspromise": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
|
||||
"integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="
|
||||
},
|
||||
"node_modules/@protobufjs/base64": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz",
|
||||
"integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="
|
||||
},
|
||||
"node_modules/@protobufjs/codegen": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz",
|
||||
"integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g=="
|
||||
},
|
||||
"node_modules/@protobufjs/eventemitter": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz",
|
||||
"integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg=="
|
||||
},
|
||||
"node_modules/@protobufjs/fetch": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz",
|
||||
"integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==",
|
||||
"dependencies": {
|
||||
"@protobufjs/aspromise": "^1.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@protobufjs/float": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz",
|
||||
"integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="
|
||||
},
|
||||
"node_modules/@protobufjs/path": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz",
|
||||
"integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="
|
||||
},
|
||||
"node_modules/@protobufjs/pool": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz",
|
||||
"integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="
|
||||
},
|
||||
"node_modules/@protobufjs/utf8": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz",
|
||||
"integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug=="
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "26.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz",
|
||||
"integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==",
|
||||
"dependencies": {
|
||||
"undici-types": "~8.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/asn1": {
|
||||
"version": "0.2.6",
|
||||
"resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz",
|
||||
"integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==",
|
||||
"dependencies": {
|
||||
"safer-buffer": "~2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
]
|
||||
},
|
||||
"node_modules/bcrypt-pbkdf": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
|
||||
"integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==",
|
||||
"dependencies": {
|
||||
"tweetnacl": "^0.14.3"
|
||||
}
|
||||
},
|
||||
"node_modules/bl": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
|
||||
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
|
||||
"dependencies": {
|
||||
"buffer": "^5.5.0",
|
||||
"inherits": "^2.0.4",
|
||||
"readable-stream": "^3.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer": {
|
||||
"version": "5.7.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
|
||||
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"dependencies": {
|
||||
"base64-js": "^1.3.1",
|
||||
"ieee754": "^1.1.13"
|
||||
}
|
||||
},
|
||||
"node_modules/buildcheck": {
|
||||
"version": "0.0.7",
|
||||
"resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz",
|
||||
"integrity": "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/chownr": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
|
||||
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="
|
||||
},
|
||||
"node_modules/cliui": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
|
||||
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
|
||||
"dependencies": {
|
||||
"string-width": "^4.2.0",
|
||||
"strip-ansi": "^6.0.1",
|
||||
"wrap-ansi": "^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/color-name": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
|
||||
},
|
||||
"node_modules/cpu-features": {
|
||||
"version": "0.0.10",
|
||||
"resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz",
|
||||
"integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==",
|
||||
"hasInstallScript": true,
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"buildcheck": "~0.0.6",
|
||||
"nan": "^2.19.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/docker-modem": {
|
||||
"version": "5.0.7",
|
||||
"resolved": "https://registry.npmjs.org/docker-modem/-/docker-modem-5.0.7.tgz",
|
||||
"integrity": "sha512-XJgGhoR/CLpqshm4d3L7rzH6t8NgDFUIIpztYlLHIApeJjMZKYJMz2zxPsYxnejq5h3ELYSw/RBsi3t5h7gNTA==",
|
||||
"dependencies": {
|
||||
"debug": "^4.1.1",
|
||||
"readable-stream": "^3.5.0",
|
||||
"split-ca": "^1.0.1",
|
||||
"ssh2": "^1.15.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dockerode": {
|
||||
"version": "4.0.12",
|
||||
"resolved": "https://registry.npmjs.org/dockerode/-/dockerode-4.0.12.tgz",
|
||||
"integrity": "sha512-/bCZd6KlGcjZO8Buqmi/vXuqEGVEZ0PNjx/biBNqJD3MhK9DmdiAuKxqfNhflgDESDIiBz3qF+0e55+CpnrUcw==",
|
||||
"dependencies": {
|
||||
"@balena/dockerignore": "^1.0.2",
|
||||
"@grpc/grpc-js": "^1.11.1",
|
||||
"@grpc/proto-loader": "^0.7.13",
|
||||
"docker-modem": "^5.0.7",
|
||||
"protobufjs": "^7.3.2",
|
||||
"tar-fs": "^2.1.4",
|
||||
"uuid": "^10.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="
|
||||
},
|
||||
"node_modules/end-of-stream": {
|
||||
"version": "1.4.5",
|
||||
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
||||
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
|
||||
"dependencies": {
|
||||
"once": "^1.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/escalade": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/eventemitter3": {
|
||||
"version": "4.0.7",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
|
||||
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
|
||||
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/RubenVerborgh"
|
||||
}
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"debug": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/fs-constants": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
|
||||
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="
|
||||
},
|
||||
"node_modules/get-caller-file": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||
"engines": {
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
}
|
||||
},
|
||||
"node_modules/http-proxy": {
|
||||
"version": "1.18.1",
|
||||
"resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz",
|
||||
"integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==",
|
||||
"dependencies": {
|
||||
"eventemitter3": "^4.0.0",
|
||||
"follow-redirects": "^1.0.0",
|
||||
"requires-port": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ieee754": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
]
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
|
||||
},
|
||||
"node_modules/is-fullwidth-code-point": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash.camelcase": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz",
|
||||
"integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA=="
|
||||
},
|
||||
"node_modules/long": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
|
||||
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="
|
||||
},
|
||||
"node_modules/mkdirp-classic": {
|
||||
"version": "0.5.3",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
|
||||
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
|
||||
},
|
||||
"node_modules/nan": {
|
||||
"version": "2.28.0",
|
||||
"resolved": "https://registry.npmjs.org/nan/-/nan-2.28.0.tgz",
|
||||
"integrity": "sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ==",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/once": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
|
||||
"dependencies": {
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/protobufjs": {
|
||||
"version": "7.6.5",
|
||||
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz",
|
||||
"integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@protobufjs/aspromise": "^1.1.2",
|
||||
"@protobufjs/base64": "^1.1.2",
|
||||
"@protobufjs/codegen": "^2.0.5",
|
||||
"@protobufjs/eventemitter": "^1.1.1",
|
||||
"@protobufjs/fetch": "^1.1.1",
|
||||
"@protobufjs/float": "^1.0.2",
|
||||
"@protobufjs/path": "^1.1.2",
|
||||
"@protobufjs/pool": "^1.1.0",
|
||||
"@protobufjs/utf8": "^1.1.1",
|
||||
"@types/node": ">=13.7.0",
|
||||
"long": "^5.3.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pump": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
|
||||
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
|
||||
"dependencies": {
|
||||
"end-of-stream": "^1.1.0",
|
||||
"once": "^1.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/readable-stream": {
|
||||
"version": "3.6.2",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
||||
"dependencies": {
|
||||
"inherits": "^2.0.3",
|
||||
"string_decoder": "^1.1.1",
|
||||
"util-deprecate": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/requires-port": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
|
||||
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ=="
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
]
|
||||
},
|
||||
"node_modules/safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
|
||||
},
|
||||
"node_modules/split-ca": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/split-ca/-/split-ca-1.0.1.tgz",
|
||||
"integrity": "sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ=="
|
||||
},
|
||||
"node_modules/ssh2": {
|
||||
"version": "1.17.0",
|
||||
"resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz",
|
||||
"integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"asn1": "^0.2.6",
|
||||
"bcrypt-pbkdf": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.16.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"cpu-features": "~0.0.10",
|
||||
"nan": "^2.23.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/tar-fs": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz",
|
||||
"integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==",
|
||||
"dependencies": {
|
||||
"chownr": "^1.1.1",
|
||||
"mkdirp-classic": "^0.5.2",
|
||||
"pump": "^3.0.0",
|
||||
"tar-stream": "^2.1.4"
|
||||
}
|
||||
},
|
||||
"node_modules/tar-stream": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
|
||||
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
|
||||
"dependencies": {
|
||||
"bl": "^4.0.3",
|
||||
"end-of-stream": "^1.4.1",
|
||||
"fs-constants": "^1.0.0",
|
||||
"inherits": "^2.0.3",
|
||||
"readable-stream": "^3.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/tweetnacl": {
|
||||
"version": "0.14.5",
|
||||
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",
|
||||
"integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA=="
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
|
||||
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz",
|
||||
"integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==",
|
||||
"deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"bin": {
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
||||
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
"string-width": "^4.1.0",
|
||||
"strip-ansi": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/wrappy": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs": {
|
||||
"version": "17.7.3",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz",
|
||||
"integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==",
|
||||
"dependencies": {
|
||||
"cliui": "^8.0.1",
|
||||
"escalade": "^3.1.1",
|
||||
"get-caller-file": "^2.0.5",
|
||||
"require-directory": "^2.1.1",
|
||||
"string-width": "^4.2.3",
|
||||
"y18n": "^5.0.5",
|
||||
"yargs-parser": "^21.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs-parser": {
|
||||
"version": "21.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
|
||||
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "rootnode-lab-router",
|
||||
"version": "0.1.0",
|
||||
"description": "RootNode Academy Phase 4.1 — Lab Host Router. Runs on the same Docker host as student Labs; resolves an opaque routing token to a local container and reverse-proxies the Platform Gateway's data-plane traffic to it. See docs/PHASE4_1_PREPRODUCTION_HARDENING.md §1-3.",
|
||||
"private": true,
|
||||
"main": "src/server.js",
|
||||
"scripts": {
|
||||
"start": "node src/server.js",
|
||||
"test": "node test/run.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"dockerode": "^4.0.2",
|
||||
"http-proxy": "^1.18.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// Token -> local container resolution, split out from server.js so it can be unit-tested
|
||||
// against a real (or fake) Docker client without needing TLS material or a bound port.
|
||||
// See server.js's header comment for the full design rationale.
|
||||
|
||||
const ROUTING_TOKEN_LABEL = 'rootnode.routing-token';
|
||||
|
||||
function tokenFingerprint(token) {
|
||||
return token ? `${token.slice(0, 6)}…(${token.length})` : '(none)';
|
||||
}
|
||||
|
||||
// Resolves a routing token to a live local container's reachable base URL, purely from local
|
||||
// Docker state — no Postgres, no Provisioner call. Returns null for "no such token" AND "token
|
||||
// belonged to a destroyed/replaced container" — both cases the caller treats identically (404).
|
||||
async function resolveTarget(docker, token) {
|
||||
if (!token || typeof token !== 'string' || token.length < 16) return null; // reject garbage before even hitting Docker
|
||||
const containers = await docker.listContainers({
|
||||
filters: JSON.stringify({ label: [`${ROUTING_TOKEN_LABEL}=${token}`], status: ['running'] }),
|
||||
});
|
||||
if (containers.length !== 1) return null; // 0 = unknown/destroyed; >1 should never happen (label is per-env-unique) but fail closed either way
|
||||
const info = containers[0];
|
||||
const networks = info.NetworkSettings && info.NetworkSettings.Networks;
|
||||
if (!networks) return null;
|
||||
const netName = Object.keys(networks)[0];
|
||||
const ip = netName && networks[netName].IPAddress;
|
||||
if (!ip) return null;
|
||||
return `http://${ip}:80`; // every Lab container listens on 80 internally — see docs/LAB_ARCHITECTURE.md
|
||||
}
|
||||
|
||||
module.exports = { resolveTarget, tokenFingerprint, ROUTING_TOKEN_LABEL };
|
||||
@@ -0,0 +1,103 @@
|
||||
// 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)`));
|
||||
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env node
|
||||
// Fast, isolated unit test for resolve.js's token->container logic — no live Docker daemon, no
|
||||
// TLS material, no network. Uses a fake dockerode-shaped client so this runs in milliseconds
|
||||
// as part of every regression pass. The REAL cross-host proxying behavior (mTLS handshake,
|
||||
// actual container resolution, HTTP+WS traffic) is covered by the two-host simulation harness
|
||||
// instead — see infra/scripts/simulate-two-host/README.md and
|
||||
// docs/PHASE4_1_PREPRODUCTION_HARDENING.md §6, not duplicated here.
|
||||
|
||||
const assert = require('assert');
|
||||
const { resolveTarget, tokenFingerprint } = require('../src/resolve');
|
||||
|
||||
function fakeDocker(containers) {
|
||||
return {
|
||||
listContainers: async ({ filters }) => {
|
||||
const parsed = JSON.parse(filters);
|
||||
const wantLabel = parsed.label[0];
|
||||
const wantStatus = parsed.status[0];
|
||||
return containers.filter((c) => c.Labels && `${Object.keys(c.Labels)[0]}=${Object.values(c.Labels)[0]}` === wantLabel && c.State === wantStatus);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const runningContainer = {
|
||||
Labels: { 'rootnode.routing-token': 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' },
|
||||
State: 'running',
|
||||
NetworkSettings: { Networks: { 'rootnode-lab-net-abc': { IPAddress: '172.20.0.5' } } },
|
||||
};
|
||||
|
||||
{
|
||||
const docker = fakeDocker([runningContainer]);
|
||||
const target = await resolveTarget(docker, 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa');
|
||||
assert.strictEqual(target, 'http://172.20.0.5:80', 'known running token resolves to its container');
|
||||
console.log('ok — known token resolves to the correct container address');
|
||||
}
|
||||
|
||||
{
|
||||
const docker = fakeDocker([runningContainer]);
|
||||
const target = await resolveTarget(docker, 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb');
|
||||
assert.strictEqual(target, null, 'unknown token resolves to nothing');
|
||||
console.log('ok — unknown token resolves to null (404 at the HTTP layer)');
|
||||
}
|
||||
|
||||
{
|
||||
// Simulates a destroyed/reset environment: the container that used to match this label is
|
||||
// gone (not present in the list at all — a real reset/destroy removes the old container).
|
||||
const docker = fakeDocker([]);
|
||||
const target = await resolveTarget(docker, 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa');
|
||||
assert.strictEqual(target, null, 'stale token (container destroyed) resolves to nothing');
|
||||
console.log('ok — stale/destroyed-environment token resolves to null, same as unknown');
|
||||
}
|
||||
|
||||
{
|
||||
const docker = fakeDocker([runningContainer]);
|
||||
for (const garbage of [undefined, null, '', 'short', 123, {}]) {
|
||||
const target = await resolveTarget(docker, garbage);
|
||||
assert.strictEqual(target, null, `garbage input ${JSON.stringify(garbage)} must not resolve`);
|
||||
}
|
||||
console.log('ok — garbage/malformed tokens are rejected before ever touching Docker');
|
||||
}
|
||||
|
||||
{
|
||||
assert.strictEqual(tokenFingerprint('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'), 'aaaaaa…(32)');
|
||||
assert.strictEqual(tokenFingerprint(undefined), '(none)');
|
||||
const fp = tokenFingerprint('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa');
|
||||
assert.ok(!fp.includes('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'), 'fingerprint never contains the full token — never logged in plaintext');
|
||||
console.log('ok — token fingerprint for logs never reveals the full token');
|
||||
}
|
||||
|
||||
console.log('\nAll lab-router unit tests passed.');
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error(e); process.exit(1); });
|
||||
@@ -1,19 +1,31 @@
|
||||
# Phase 1 lab_hosts seed — applied to the DB at Provisioner startup (config.js
|
||||
# applyLabHostsSeed), not managed via a runtime API (no host-management endpoints in Phase 1,
|
||||
# matching "don't overbuild scheduling yet" — see docs/PHASE1_PLATFORM_FOUNDATION.md §4).
|
||||
# lab_hosts seed — applied to the DB at Provisioner startup (config.js applyLabHostsSeed), not
|
||||
# managed via a runtime API (no host-management endpoints yet — see
|
||||
# docs/PHASE1_PLATFORM_FOUNDATION.md §4, "don't overbuild scheduling yet").
|
||||
#
|
||||
# Exactly one enabled row for Phase 1. Swapping this for a remote tcp://<lab-host-vm>:2376
|
||||
# entry with TLS material is the entire code change needed to point at a real, separate Lab
|
||||
# Host VM in production — see docs/LAB_ARCHITECTURE.md §1 and correction D.
|
||||
# `endpoint`/`tls_*_path` — the Provisioner's CONTROL-plane connection to this host's Docker
|
||||
# daemon (create/start/stop/remove containers). Unchanged since Phase 1.
|
||||
#
|
||||
# `routing_mode`/`router_endpoint` — Phase 4.1 addition, the Gateway's DATA-plane path to a
|
||||
# running Lab on this host. See docs/PHASE4_1_PREPRODUCTION_HARDENING.md §1 and
|
||||
# docs/PRODUCTION_NETWORK_SECURITY.md.
|
||||
# direct — Gateway reaches env.internal_ip:internal_port itself. Only correct when Gateway
|
||||
# and this lab_host's Docker daemon are on the same network namespace (dev,
|
||||
# single-host). This is the default and requires no router_endpoint.
|
||||
# router — Gateway reaches this lab_host's Lab Host Router (platform/lab-router) instead,
|
||||
# over mTLS, and never touches a Docker-internal address. Required once this
|
||||
# lab_host is a genuinely separate VM from the Gateway.
|
||||
|
||||
lab_hosts:
|
||||
- lab_host_id: dev-local
|
||||
display_name: "Local development Docker socket"
|
||||
endpoint: "unix:///var/run/docker.sock"
|
||||
enabled: true
|
||||
routing_mode: direct
|
||||
|
||||
# Production example (not active — shown for reference; uncomment and fill in when a real
|
||||
# Lab Host VM exists):
|
||||
# Production example (not active — shown for reference; uncomment and fill in once a real
|
||||
# Lab Host VM exists). CONTROL plane: Provisioner -> dockerd, mTLS, port 2376. DATA plane:
|
||||
# Gateway -> Lab Host Router, mTLS, router's own port (infra default 8443) — see
|
||||
# infra/docker/lab-host/docker-compose.production.yml and infra/ansible/roles/lab-router.
|
||||
# - lab_host_id: labhost-01
|
||||
# display_name: "RootNode Lab Host VM 01"
|
||||
# endpoint: "tcp://10.20.30.5:2376"
|
||||
@@ -21,3 +33,5 @@ lab_hosts:
|
||||
# tls_cert_path: /etc/rootnode/tls/labhost-01/cert.pem
|
||||
# tls_key_path: /etc/rootnode/tls/labhost-01/key.pem
|
||||
# enabled: true
|
||||
# routing_mode: router
|
||||
# router_endpoint: "https://10.20.30.5:8443"
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
-- lab_hosts routing mode — Phase 4.1, docs/PHASE4_1_PREPRODUCTION_HARDENING.md §1.
|
||||
--
|
||||
-- Removes the Gateway's dependency on env.internal_ip being reachable directly, which only
|
||||
-- holds when Gateway and the Lab container share a Docker daemon (true in dev, false once
|
||||
-- Labs run on a genuinely separate rn-lab-host-01 VM). A lab_host is either:
|
||||
-- 'direct' — Gateway reaches env.internal_ip:internal_port itself. Correct when Gateway and
|
||||
-- this lab_host's Docker daemon share a network namespace (dev, single-host).
|
||||
-- 'router' — Gateway reaches this lab_host's Lab Host Router (router_endpoint) over mTLS
|
||||
-- instead, carrying the environment's routing_token in a header; the Router
|
||||
-- resolves that token to a local container on ITS OWN host and reverse-proxies.
|
||||
-- Correct once this lab_host is a genuinely separate VM.
|
||||
--
|
||||
-- Existing 'dev-local' row keeps routing_mode='direct' (the default) — no behavior change for
|
||||
-- current dev/single-host setups. All existing lab_hosts rows and every environments row are
|
||||
-- untouched; this only adds columns.
|
||||
|
||||
ALTER TABLE lab_hosts ADD COLUMN IF NOT EXISTS routing_mode TEXT NOT NULL DEFAULT 'direct';
|
||||
ALTER TABLE lab_hosts ADD COLUMN IF NOT EXISTS router_endpoint TEXT;
|
||||
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE lab_hosts ADD CONSTRAINT lab_hosts_routing_mode_check
|
||||
CHECK (routing_mode IN ('direct', 'router'));
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE lab_hosts ADD CONSTRAINT lab_hosts_router_endpoint_required_check
|
||||
CHECK (routing_mode = 'direct' OR router_endpoint IS NOT NULL);
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
@@ -24,14 +24,19 @@ function loadLabHostsSeed() {
|
||||
async function applyLabHostsSeed(pool) {
|
||||
const hosts = loadLabHostsSeed();
|
||||
for (const h of hosts) {
|
||||
const routingMode = h.routing_mode || 'direct'; // Phase 4.1 — see migration 004's comment
|
||||
if (routingMode === 'router' && !h.router_endpoint) {
|
||||
throw new Error(`lab_host '${h.lab_host_id}' has routing_mode: router but no router_endpoint configured`);
|
||||
}
|
||||
await pool.query(
|
||||
`INSERT INTO lab_hosts (lab_host_id, display_name, endpoint, tls_ca_path, tls_cert_path, tls_key_path, enabled)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
`INSERT INTO lab_hosts (lab_host_id, display_name, endpoint, tls_ca_path, tls_cert_path, tls_key_path, enabled, routing_mode, router_endpoint)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
ON CONFLICT (lab_host_id) DO UPDATE SET
|
||||
display_name = excluded.display_name, endpoint = excluded.endpoint,
|
||||
tls_ca_path = excluded.tls_ca_path, tls_cert_path = excluded.tls_cert_path,
|
||||
tls_key_path = excluded.tls_key_path, enabled = excluded.enabled`,
|
||||
[h.lab_host_id, h.display_name, h.endpoint, h.tls_ca_path || null, h.tls_cert_path || null, h.tls_key_path || null, h.enabled !== false]
|
||||
tls_key_path = excluded.tls_key_path, enabled = excluded.enabled,
|
||||
routing_mode = excluded.routing_mode, router_endpoint = excluded.router_endpoint`,
|
||||
[h.lab_host_id, h.display_name, h.endpoint, h.tls_ca_path || null, h.tls_cert_path || null, h.tls_key_path || null, h.enabled !== false, routingMode, h.router_endpoint || null]
|
||||
);
|
||||
}
|
||||
return hosts.length;
|
||||
|
||||
@@ -40,6 +40,21 @@ async function getEnvironment(pool, environmentId) {
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
// Phase 4.1 — the Gateway's read path (GET /environments/by-token/:token) needs to know HOW
|
||||
// to reach the Lab, not just its Docker-internal address: is this lab_host 'direct' (Gateway
|
||||
// reaches env.internal_ip itself) or 'router' (Gateway must go through that lab_host's Lab
|
||||
// Host Router instead)? Joining lab_hosts here keeps that decision entirely data-driven —
|
||||
// the Gateway never hardcodes or guesses a host's routing mode.
|
||||
async function getEnvironmentByTokenWithRouting(pool, token) {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT e.*, h.routing_mode AS lab_host_routing_mode, h.router_endpoint AS lab_host_router_endpoint
|
||||
FROM environments e JOIN lab_hosts h ON h.lab_host_id = e.lab_host_id
|
||||
WHERE e.routing_token = $1`,
|
||||
[token]
|
||||
);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
async function listEnvironments(pool, studentId) {
|
||||
const { rows } = await pool.query(
|
||||
'SELECT * FROM environments WHERE student_id = $1 ORDER BY created_at DESC',
|
||||
@@ -90,6 +105,23 @@ async function provisionEnvironment({ pool, studentId, resourcePolicy, imageRef,
|
||||
throw Object.assign(new Error(`student '${studentId}' already has a non-terminal environment (${existing.environment_id})`), { code: 'ALREADY_EXISTS', existing });
|
||||
}
|
||||
|
||||
// Admission control (Phase 4.1 §26) — checked BEFORE touching Docker at all, so a full host
|
||||
// fails cleanly and fast (503, no partial network/container left behind to clean up) instead
|
||||
// of failing mid-provision with a generic 500 once Docker itself runs out of room. No-op
|
||||
// when maxConcurrentLabs isn't configured (see resourcePolicyFor's comment on why that's the
|
||||
// default).
|
||||
if (resourcePolicy.maxConcurrentLabs) {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT count(*)::int AS n FROM environments WHERE status NOT IN ('destroyed', 'error')`
|
||||
);
|
||||
if (rows[0].n >= resourcePolicy.maxConcurrentLabs) {
|
||||
throw Object.assign(
|
||||
new Error('Lab capacity is temporarily full. Try again shortly.'),
|
||||
{ code: 'CAPACITY_EXCEEDED', current: rows[0].n, max: resourcePolicy.maxConcurrentLabs }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const labHost = await selectLabHost(pool);
|
||||
const docker = clientFor(labHost);
|
||||
const routingToken = generateRoutingToken();
|
||||
@@ -145,6 +177,15 @@ async function provisionEnvironment({ pool, studentId, resourcePolicy, imageRef,
|
||||
const container = await docker.createContainer({
|
||||
name: containerName,
|
||||
Image: imageRef,
|
||||
// Phase 4.1: labeled by routing token (not environment_id alone) so the Lab Host Router
|
||||
// — which runs on this same Docker host and has no Postgres access — can resolve
|
||||
// "which local container does this opaque token address" purely from local `docker ps`,
|
||||
// with no cross-VM call back to the Provisioner needed at request time. See
|
||||
// docs/PHASE4_1_PREPRODUCTION_HARDENING.md §1, platform/lab-router/src/router.js.
|
||||
Labels: {
|
||||
'rootnode.routing-token': routingToken,
|
||||
'rootnode.environment-id': env.environment_id,
|
||||
},
|
||||
HostConfig: {
|
||||
Privileged: true, // required for k3s — see docs/LAB_ARCHITECTURE.md §7, not avoidable
|
||||
NetworkMode: networkName,
|
||||
@@ -321,6 +362,13 @@ async function resetEnvironment({ pool, environmentId, resourcePolicy, egressCon
|
||||
const container = await docker.createContainer({
|
||||
name: containerName,
|
||||
Image: env.image_ref,
|
||||
// Phase 4.1 — same routing-token label as provisionEnvironment; a reset keeps the same
|
||||
// environment_id/routing_token (same DB row) so this must relabel the RECREATED container
|
||||
// the same way, or the Lab Host Router would resolve stale/no container for this token.
|
||||
Labels: {
|
||||
'rootnode.routing-token': env.routing_token,
|
||||
'rootnode.environment-id': environmentId,
|
||||
},
|
||||
HostConfig: {
|
||||
Privileged: true,
|
||||
NetworkMode: networkName,
|
||||
@@ -453,7 +501,7 @@ async function reconcileOnStartup(pool) {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
generateRoutingToken, emitEvent, getEnvironment, listEnvironments, getActiveEnvironmentForStudent,
|
||||
generateRoutingToken, emitEvent, getEnvironment, getEnvironmentByTokenWithRouting, listEnvironments, getActiveEnvironmentForStudent,
|
||||
provisionEnvironment, destroyEnvironment, resetEnvironment,
|
||||
suspendEnvironment, resumeEnvironment, pollHealth, shortId, reconcileOnStartup,
|
||||
};
|
||||
|
||||
@@ -40,11 +40,18 @@ function egressConfig() {
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -81,7 +88,10 @@ function isValidUUID(v) {
|
||||
function asyncRoute(fn) {
|
||||
return (req, res) => fn(req, res).catch((e) => {
|
||||
console.error(`[${req.requestId}]`, e);
|
||||
const status = e.code === 'NOT_FOUND' ? 404 : e.code === 'ALREADY_EXISTS' ? 409 : 500;
|
||||
// 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 });
|
||||
});
|
||||
}
|
||||
@@ -98,6 +108,14 @@ app.get('/health', asyncRoute(async (req, res) => {
|
||||
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 || {};
|
||||
@@ -130,9 +148,12 @@ app.get('/environments', asyncRoute(async (req, res) => {
|
||||
// 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) => {
|
||||
const { rows } = await getPool().query('SELECT * FROM environments WHERE routing_token = $1', [req.params.token]);
|
||||
if (!rows.length) return res.status(404).json({ error: 'not found', request_id: req.requestId });
|
||||
res.json(redactRow(rows[0]));
|
||||
// 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
|
||||
|
||||
Reference in New Issue
Block a user