Files
ced4568 eae8e48d43 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.
2026-08-24 20:14:39 -05:00

74 lines
3.3 KiB
JavaScript

#!/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); });