The route-forwarding bed installs the catalogue's authority, proxy and consumer; the whole-mesh bed installs the vault first #46

Merged
jschoubben merged 9 commits from multiple-fixes into main 2026-09-21 20:58:25 +00:00
Showing only changes of commit acb8d3da88 - Show all commits
+7 -60
View File
@@ -179,6 +179,9 @@ const NOVOX: Mod[] = [
{ name: "mongodb", containers: ["mongo", "mesh-mongodb"] }, { name: "mongodb", containers: ["mongo", "mesh-mongodb"] },
{ name: "mssql", containers: ["mssql", "mesh-mssql"] }, { name: "mssql", containers: ["mssql", "mesh-mssql"] },
{ name: "lavinmq", containers: ["lavinmq", "mesh-lavinmq"] }, { name: "lavinmq", containers: ["lavinmq", "mesh-lavinmq"] },
// The vault, before everything that keeps a secret from it: gitea, umami, influxdb, mailu
// require one (novox/hq ADRs 0085, 0094).
{ name: "mesh-vault", containers: ["mesh-vault"] },
// ADR 0066: the proxy now REQUIRES an `acme-ca`, so the bed must assign a provider of one or // ADR 0066: the proxy now REQUIRES an `acme-ca`, so the bed must assign a provider of one or
// route-proxy is unresolvable and takes every routed module down with it. step-ca is that // route-proxy is unresolvable and takes every routed module down with it. step-ca is that
// provider, on the anchor, at mesh scope. // provider, on the anchor, at mesh scope.
@@ -306,14 +309,11 @@ const CREDENTIALS: { node: string; module: string; name: string; crash: string }
{ node: "ace", module: "home-assistant", name: "token", crash: "no Home Assistant token" }, { node: "ace", module: "home-assistant", name: "token", crash: "no Home Assistant token" },
{ node: "ace", module: "nzbget", name: "password", crash: "NZBGet not configured" }, { node: "ace", module: "nzbget", name: "password", crash: "NZBGet not configured" },
{ node: "ace", module: "qbittorrent", name: "password", crash: "qBittorrent not configured" }, { node: "ace", module: "qbittorrent", name: "password", crash: "qBittorrent not configured" },
{ node: "novox", module: "umami", name: "admin", crash: "admin password is not set" },
]; ];
/** Operator secrets for the credential modules that own-secret their whole app (mailu, de-spiegel). */ /** Operator secrets for the credential modules that own-secret their whole app (de-spiegel,
* amqp-email-forwarder). mailu's and umami's are kept in the vault now (ADR 0094), not delivered. */
const OPERATOR_SECRETS: { node: string; module: string; name: string; value: string }[] = [ const OPERATOR_SECRETS: { node: string; module: string; name: string; value: string }[] = [
{ node: "novox", module: "mailu", name: "secret-key", value: "0123456789abcdef0123456789abcdef" },
{ node: "novox", module: "mailu", name: "admin", value: "MailuAdminFakePass123" },
{ node: "novox", module: "mailu", name: "api-token", value: "mailuapitokenfake0123456789abcd" },
{ node: "novox", module: "de-spiegel", name: "smtp-user", value: "despiegel-smtp-fake" }, { node: "novox", module: "de-spiegel", name: "smtp-user", value: "despiegel-smtp-fake" },
{ node: "novox", module: "de-spiegel", name: "smtp-pass", value: "despiegel-pass-fake" }, { node: "novox", module: "de-spiegel", name: "smtp-pass", value: "despiegel-pass-fake" },
{ node: "novox", module: "amqp-email-forwarder", name: "smtp-user", value: "eef-smtp-fake" }, { node: "novox", module: "amqp-email-forwarder", name: "smtp-user", value: "eef-smtp-fake" },
@@ -410,56 +410,8 @@ async function psMapOf(node: string): Promise<Map<string, string>> {
return map; return map;
} }
/** // ADR 0098: the internal authority makes its own root at first start and serves it; the proxy
* ADR 0066: the internal CA is initialised FROM AN OPERATOR'S ROOT — it does not mint its own. // fetches it. No operator root is delivered — the mesh mints only the authority's password.
*
* So the bed has to be an operator. The material is made on the anchor with openssl and handed to
* the mesh through the real `secret accept` path, exactly as a person would: the mesh cannot invent
* a PEM, and the random 32 bytes it makes for an own-secret nobody supplied would leave step-ca
* crash-looping on a root key that is not a key.
*/
async function deliverCaRoot(): Promise<boolean> {
const made = await on(CONTROL, [
"set -e",
"mkdir -p /tmp/ca && cd /tmp/ca",
// No trailing newline on a password file: step-ca reads the file as the password itself.
"openssl rand -hex 16 | tr -d '\\n' > key-password",
"openssl ecparam -genkey -name prime256v1 -out root.unenc",
"openssl ec -in root.unenc -aes256 -passout file:key-password -out root.key",
"rm -f root.unenc",
"openssl req -x509 -new -key root.key -passin file:key-password -sha256 -days 3650" +
` -out root.crt -subj "/CN=Mesh Internal CA/O=Novox Mesh Lab"`,
// Readable by the control plane, which is not root. Its image is FROM scratch and runs as
// 65534, and `docker cp` keeps the ownership and mode a file had outside — openssl writes a
// private key 0600 root-owned, so the copy landed unreadable and `secret accept` failed with
// `open /ca-root-key: permission denied`. The CA then crash-looped on a root it never got.
// Chowning it inside the container is not available: there is no shell in there to do it with.
//
// Safe here and nowhere else: these three exist for the seconds between being written and
// being sealed to the machine, on a lab node, for a CA thrown away with the scenario.
"chmod 0644 /tmp/ca/root.crt /tmp/ca/root.key /tmp/ca/key-password",
"docker cp /tmp/ca/root.crt mesh-controller:/ca-root-cert",
"docker cp /tmp/ca/root.key mesh-controller:/ca-root-key",
"docker cp /tmp/ca/key-password mesh-controller:/ca-root-key-password",
].join("\n"), 180_000);
if (!made.ok) {
console.log(`CA ROOT NOT MADE on ${CONTROL}:\n${made.out.split("\n").slice(-8).join("\n")}`);
return false;
}
for (const [name, file] of [
["root-cert", "/ca-root-cert"],
["root-key", "/ca-root-key"],
["root-key-password", "/ca-root-key-password"],
] as const) {
try {
await mesh(`secret accept ${CONTROL} step-ca ${name} --from ${file}`);
} catch (err) {
console.log(`CA ROOT ACCEPT FAILED (${name}): ${(err as Error).message.split("\n").slice(0, 3).join(" | ")}`);
return false;
}
}
return true;
}
/** What a module's manifest says its route label is, or "" if it contributes no route. */ /** What a module's manifest says its route label is, or "" if it contributes no route. */
function routeLabelOf(name: string): string { function routeLabelOf(name: string): string {
@@ -889,10 +841,6 @@ test("the full mesh forms across the access point and both server sets converge"
} }
} }
// ADR 0066: the CA's root, before the push that would otherwise deliver a random 32 bytes for it.
const caRootDelivered = assigned["novox"]!.has("step-ca") ? await deliverCaRoot() : false;
if (!caRootDelivered) console.log("ADR 0066: no operator root delivered; step-ca cannot initialise.");
// ONE push per node (workstations first — cheap — then the heavy service nodes). // ONE push per node (workstations first — cheap — then the heavy service nodes).
const pushError: Record<string, string> = {}; const pushError: Record<string, string> = {};
for (const node of ["shanks", "g14", "novox", "ace"]) { for (const node of ["shanks", "g14", "novox", "ace"]) {
@@ -1027,7 +975,6 @@ test("the full mesh forms across the access point and both server sets converge"
? (await on("novox", `curl -s -o /dev/null -w '%{http_code}' --max-time 10 -H 'Host: ${probe.name}' http://127.0.0.1/`)).out.trim() ? (await on("novox", `curl -s -o /dev/null -w '%{http_code}' --max-time 10 -H 'Host: ${probe.name}' http://127.0.0.1/`)).out.trim()
: ""; : "";
adr.push(`\n proxy answers for ${probe?.name ?? "(nothing composed)"}: ${servedCode || "no answer"}`); adr.push(`\n proxy answers for ${probe?.name ?? "(nothing composed)"}: ${servedCode || "no answer"}`);
adr.push(` operator root delivered to step-ca: ${caRootDelivered}`);
adr.push(` step-ca container: ${psMaps["novox"]?.get("step-ca") ?? "MISSING"}`); adr.push(` step-ca container: ${psMaps["novox"]?.get("step-ca") ?? "MISSING"}`);
console.log(adr.join("\n")); console.log(adr.join("\n"));