Beds for the vault and for genesis's root secrets #39
@@ -109,9 +109,9 @@ const CONTROL_PLANE = { module: "mesh-controller", repo: "mesh-controller", path
|
||||
* is not one.
|
||||
*/
|
||||
const MUST_HOLD = ["mesh-controller", "distribution", "builder", "mesh-tools", "postgres",
|
||||
"mesh-catalog", "lavinmq", "amqp-ping"];
|
||||
"mesh-catalog", "lavinmq", "mesh-vault", "amqp-ping"];
|
||||
const MUST_RUN = ["mesh-controller", "mesh-registry", "mesh-broker", "mesh-store",
|
||||
"mesh-postgres", "mesh-catalog", "mesh-lavinmq", "amqp-ping"];
|
||||
"mesh-postgres", "mesh-catalog", "mesh-lavinmq", "mesh-vault", "amqp-ping"];
|
||||
/** Named once, because the step title is also how later steps say what they waited on. */
|
||||
const NEEDS = "the mesh runs a broker for that module to talk to";
|
||||
const GENESIS = "a bare machine becomes a mesh of one, raised by the installer";
|
||||
@@ -133,6 +133,7 @@ const DESCRIBES = "the control plane can describe the mesh, and what it says is
|
||||
const CATALOGUED = "the catalogue holds every module this mesh built";
|
||||
const NETWORK = "the machine's networking is what the modules asked for";
|
||||
const DECLARED = "every resource the mesh declared is true on the machine";
|
||||
const ROOT_SECRETS = "the root secrets are the mesh's own, sealed to an operator key, and a person can recover them";
|
||||
const FOLLOWS = "a change to a module's source reaches the machine on its own";
|
||||
const STORE_UPGRADES = "the store is upgraded in place, and the controller reads it through the window";
|
||||
const BROKER_UPGRADES = "the broker is upgraded in place, and the mesh talks over the window";
|
||||
@@ -798,10 +799,13 @@ before(async () => {
|
||||
// broker's loopback, reached by joining its network namespace.
|
||||
const image = (await on(CONTROL,
|
||||
`docker inspect -f '{{.Config.Image}}' mesh-catalog`)).out.trim();
|
||||
// The administrator's password is the one genesis made, kept where the lavinmq module's own
|
||||
// secret lives (novox/hq issue 071) — the image's default no longer opens the broker.
|
||||
const adminPassword = (await must(CONTROL, `cat /var/lib/lavinmq-module/admin.secret`)).trim();
|
||||
const ask = async (tool: string, args = "{}") =>
|
||||
must(CONTROL,
|
||||
`docker run --rm --network container:mesh-broker ` +
|
||||
`-e MESH_BROKER_URL=amqp://guest:guest@127.0.0.1:5672/ ` +
|
||||
`-e MESH_BROKER_URL=amqp://guest:${encodeURIComponent(adminPassword)}@127.0.0.1:5672/ ` +
|
||||
`${image} invoke mesh-catalog ${tool} ${quote(args)}`,
|
||||
120_000);
|
||||
|
||||
@@ -961,6 +965,73 @@ before(async () => {
|
||||
].filter(Boolean).join("\n");
|
||||
});
|
||||
|
||||
// ---- V5. AND ITS ROOT SECRETS ARE ITS OWN ------------------------------------------------------
|
||||
//
|
||||
// novox/hq ADR 0085 (amended), issue 071. The template raises the store and broker with fixed
|
||||
// credentials; the installer replaces them with values it made, seals every secret a module
|
||||
// holds for itself to an operator key it made first, installs the vault to keep those copies,
|
||||
// and writes the export beside the key. Checked from outside every container — a login over
|
||||
// loopback inside the store's container is trusted and proves nothing (design 13).
|
||||
await step("V5", ROOT_SECRETS, DECLARED, async () => {
|
||||
const said: string[] = [];
|
||||
const storeImage = (await must(CONTROL, `docker inspect -f '{{.Config.Image}}' mesh-store`)).trim();
|
||||
const psql = async (password: string) =>
|
||||
on(CONTROL,
|
||||
`docker run --rm --network host ${storeImage} psql ` +
|
||||
`${quote(`postgresql://postgres:${encodeURIComponent(password)}@127.0.0.1:5432/postgres?sslmode=disable`)} -tAc 'select 1'`,
|
||||
60_000);
|
||||
|
||||
// 1. The template's password does not open the store.
|
||||
const stale = await psql("bootstrap");
|
||||
assert.ok(!stale.ok || !/^1$/m.test(stale.out), `the template's password still opens the store:\n${stale.out}`);
|
||||
said.push(` bootstrap refused by the store`);
|
||||
|
||||
// 2. The operator key and the export are beside the bundle, and only root can read them.
|
||||
for (const f of ["/var/lib/mesh-host/operator.key", "/var/lib/mesh-host/root-secrets.export.json", "/var/lib/mesh-host/foundation.lock"]) {
|
||||
const mode = (await must(CONTROL, `stat -c %a ${f}`)).trim();
|
||||
assert.equal(mode, "600", `${f} is mode ${mode}`);
|
||||
}
|
||||
const exported = await must(CONTROL, `cat /var/lib/mesh-host/root-secrets.export.json`);
|
||||
const doc = JSON.parse(exported) as { kept: { node: string; module: string; name: string; origin: string }[]; unrecoverable?: unknown[] };
|
||||
for (const want of [["postgres", "superuser"], ["lavinmq", "admin"], ["mesh-controller", "inventory"]]) {
|
||||
assert.ok(doc.kept.some((k) => k.module === want[0] && k.name === want[1]),
|
||||
`the export lacks ${want.join("/")}:\n${doc.kept.map((k) => `${k.module}/${k.name}`).join(" ")}`);
|
||||
}
|
||||
const superuser = (await must(CONTROL, `cat /var/lib/postgres/superuser.secret`)).trim();
|
||||
const admin = (await must(CONTROL, `cat /var/lib/lavinmq-module/admin.secret`)).trim();
|
||||
assert.ok(!exported.includes(superuser) && !exported.includes(admin), "the export holds a plaintext root secret");
|
||||
said.push(` exported ${doc.kept.length} secret(s) sealed to the operator key; ${(doc.unrecoverable ?? []).length} not recoverable`);
|
||||
|
||||
// 3. The vault keeps the same export on its own disk.
|
||||
const kept = await must(CONTROL, `cat /var/lib/mesh-vault/root/export.json`);
|
||||
assert.ok(kept.includes('"kept"') && !kept.includes(superuser), "the vault's copy is missing or holds plaintext");
|
||||
said.push(` vault keeps the export at /var/lib/mesh-vault/root/export.json`);
|
||||
|
||||
// 4. A person with the key recovers the store's superuser from the export alone — off the
|
||||
// mesh, in a throwaway container with no store or broker in reach — and it opens the store.
|
||||
// The copies are relaxed to a scratch directory for the test only: the operator's real
|
||||
// files stay root-owned at 0600 above.
|
||||
const controllerImage = (await must(CONTROL, `docker inspect -f '{{.Config.Image}}' mesh-controller`)).trim();
|
||||
await must(CONTROL, `rm -rf /tmp/operator && mkdir -p /tmp/operator && chmod 777 /tmp/operator && ` +
|
||||
`cp /var/lib/mesh-host/operator.key /var/lib/mesh-host/root-secrets.export.json /tmp/operator/ && chmod 644 /tmp/operator/*`);
|
||||
const recover = async (module: string, name: string) => {
|
||||
await must(CONTROL,
|
||||
`docker run --rm -v /tmp/operator:/work --entrypoint /mesh-controller ${controllerImage} ` +
|
||||
`secret recover ${CONTROL} ${module} ${name} --key /work/operator.key --from-export /work/root-secrets.export.json --out /work/${module}.${name}`,
|
||||
120_000);
|
||||
return (await must(CONTROL, `cat /tmp/operator/${module}.${name}`)).replace(/\n$/, "");
|
||||
};
|
||||
const recoveredSuperuser = await recover("postgres", "superuser");
|
||||
assert.equal(recoveredSuperuser, superuser, "the recovered superuser is not the one the store was raised with");
|
||||
const opened = await psql(recoveredSuperuser);
|
||||
assert.ok(opened.ok && /^1$/m.test(opened.out), `the recovered superuser does not open the store:\n${opened.out}`);
|
||||
said.push(` recovered postgres/superuser with the operator key, and it opens the store`);
|
||||
const recoveredAdmin = await recover("lavinmq", "admin");
|
||||
assert.equal(recoveredAdmin, admin, "the recovered broker admin is not the one genesis made");
|
||||
said.push(` recovered lavinmq/admin with the operator key — the broker's, as V2 dialled it`);
|
||||
return said.join("\n");
|
||||
});
|
||||
|
||||
// ---- 11. A CHANGE REACHES THE MACHINE ON ITS OWN ----------------------------------------------
|
||||
//
|
||||
// The whole point of the mesh, and the capability the migration depends on: move a module's
|
||||
|
||||
Reference in New Issue
Block a user