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,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); });
|
||||
Reference in New Issue
Block a user