/** * The console must not be reachable from the data plane. * * This is asserted here, cheaply, on every pull request. The e2e suite asserts * the same thing for real by failing to reach the console from a data-plane * zone, but that suite is slow, needs Docker, and does not run on every change. * The way this boundary actually erodes is a one-line manifest edit that adds * `private_web` because someone wanted to reach the console from a laptop * without bringing up the VPN, and a gate that only fires in e2e would not catch * that until much later. * * The history is why this is a test and not a comment. The change's second draft * DID publish the console through caddy-internal, the operator refused it, and * the final verification step still said "the route resolves through * caddy-internal" for a further round after that. Prose did not hold this. */ import { describe, expect, test } from 'bun:test'; import { readFileSync, readdirSync } from 'node:fs'; import { join } from 'node:path'; import { parse } from 'yaml'; const MODULE_DIR = new URL('../../../../modules/celilo-web-console/', import.meta.url).pathname; const MANIFEST_PATH = join(MODULE_DIR, 'manifest.yml'); /** * Capabilities that give something a way in from the data plane. * * `private_web` and `public_web` publish a route through a reverse proxy. * `firewall` is how a module asks for a port forward or an ingress address. * Any of the three would put a data-plane client on a path to the control * plane. */ const INGRESS_CAPABILITIES = ['private_web', 'public_web', 'firewall']; interface CapabilityRef { name: string; } interface Manifest { id: string; requires?: { capabilities?: CapabilityRef[]; system?: { zone?: string } }; optional?: { capabilities?: CapabilityRef[] }; provides?: { capabilities?: CapabilityRef[] }; } async function loadManifest(): Promise { return parse(await Bun.file(MANIFEST_PATH).text()) as Manifest; } describe('celilo-web-console stays in the control plane', () => { test('lands in secure-mgmt and nowhere else', async () => { const manifest = await loadManifest(); expect(manifest.requires?.system?.zone).toBe('secure-mgmt'); }); test('requires no capability that could give it data-plane ingress', async () => { const manifest = await loadManifest(); const required = (manifest.requires?.capabilities ?? []).map((c) => c.name); const found = required.filter((name) => INGRESS_CAPABILITIES.includes(name)); expect(found).toEqual([]); }); test('does not pick up ingress through an OPTIONAL capability either', async () => { // Optional is the likelier hole. It reads as harmless, and the closure walk // in this same directory exists because optional edges are real edges. const manifest = await loadManifest(); const optional = (manifest.optional?.capabilities ?? []).map((c) => c.name); const found = optional.filter((name) => INGRESS_CAPABILITIES.includes(name)); expect(found).toEqual([]); }); test('provides nothing, so nothing can bind to it as a dependency', async () => { const manifest = await loadManifest(); expect(manifest.provides?.capabilities ?? []).toEqual([]); }); }); /** * The build output the manifest's `build:` step writes, which is not source. * * Excluded from the walk below for two reasons, and the second is the one that * matters. It holds two ~100MB compiled binaries, so reading it as text is * absurd. And it holds a minified React bundle, which contains almost every * short string you could think to search for — so scanning it reports ingress * that nobody wrote. Both are gitignored, so on a fresh checkout the directory * is absent entirely and the walk would silently be checking a different set of * files than it checks on a machine that has run a build. */ const BUILD_OUTPUT_DIR = 'files'; /** * Every source file in the module's deployable body. * * A walk rather than a list, because the hole this guards is a file somebody * ADDS. Listing today's files would pass over exactly the new Ansible task that * opens the port. */ function deployableBody(): { path: string; text: string }[] { const found: { path: string; text: string }[] = []; const walk = (dir: string) => { for (const entry of readdirSync(dir, { withFileTypes: true })) { if (entry.isDirectory()) { if (entry.name !== BUILD_OUTPUT_DIR) walk(join(dir, entry.name)); continue; } const path = join(dir, entry.name); found.push({ path, text: readFileSync(path, 'utf8') }); } }; for (const sub of ['terraform', 'ansible']) walk(join(MODULE_DIR, sub)); return found; } /** * How a deployable body would open a path in from the data plane. * * A reverse-proxy vhost, a DNAT or port-forward rule, a `natIp`, or a dedicated * ingress address. None belongs on a `secure-mgmt` module, and every one is a * plausible one-line addition by someone who wanted to reach the console * without bringing up the VPN. */ const INGRESS_TOKENS = [ 'caddy', 'reverse_proxy', 'natip', 'exposeservice', 'port_forward', 'portforward', 'dnat', ]; describe("the console's deployable body opens no path in", () => { test('the walk reaches the terraform and ansible it is checking', () => { // Without this the assertion below is a confident statement about an empty // set as soon as either directory is renamed — and the same is true on a // fresh checkout, where the excluded build output does not exist at all. const paths = deployableBody().map((file) => file.path); expect(paths.some((path) => path.endsWith('terraform/main.tf.tpl'))).toBe(true); expect(paths.some((path) => path.includes('/ansible/roles/'))).toBe(true); expect(paths.some((path) => path.endsWith('.service.j2'))).toBe(true); }); test('reads no build output, so a local build cannot change the verdict', () => { const paths = deployableBody().map((file) => file.path); expect(paths.filter((path) => path.includes(`/${BUILD_OUTPUT_DIR}/`))).toEqual([]); }); test('names no reverse proxy, port forward or ingress address', () => { const offenders = deployableBody() .filter((file) => INGRESS_TOKENS.some((token) => file.text.toLowerCase().includes(token))) .map((file) => file.path.slice(MODULE_DIR.length)); expect(offenders).toEqual([]); }); });