diff --git a/src/index.ts b/src/index.ts index 4c89a70..8ee966b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,3 @@ -import path from "node:path"; import { resolveWriteLease, WriteLeaseNotHeldError } from "./process-write-lease.js"; import { buildApp } from "./app.js"; import { loadConfig } from "./config.js"; @@ -9,7 +8,7 @@ import type { CultNetOperationServer } from "cultnet-ts"; import { createHeimdallRuntimePulse, buildHeimdallHealthDetail, publishHeimdallVerseState } from "./verse-state.js"; import { buildPresenceStatement, - nextPublisherSequence, + PublisherSequence, readRuntimeBundleFacts, type RuntimeBundleFacts, } from "./runtime-presence.js"; @@ -76,6 +75,7 @@ async function publishVerseState(): Promise { * work that can only ever return the same answer or fail. */ let runtimeBundleFacts: RuntimeBundleFacts | undefined; +const presenceSequence = new PublisherSequence(); async function publishHealthState(): Promise { const pulse = createHeimdallRuntimePulse(config); @@ -111,9 +111,7 @@ async function publishHealthState(): Promise { // refuses the pair, and Idunn needs the warming statement to promote it. writeLeaseSha256: null, observedAtUnixMillis, - publisherSequence: await nextPublisherSequence( - path.join(config.dataRoot, "runtime-presence-sequence"), - ), + publisherSequence: presenceSequence.next(observedAtUnixMillis), providerHealthIdentityPath: config.providerHealthIdentityPath, }); diff --git a/src/provider-health-identity.ts b/src/provider-health-identity.ts index 847d39c..ad4903f 100644 --- a/src/provider-health-identity.ts +++ b/src/provider-health-identity.ts @@ -128,6 +128,14 @@ export async function openProviderHealthIdentity( ): Promise { const descriptor = passedDescriptor(PRESENCE_IDENTITY_FD_NAME, env); if (descriptor === undefined) { + if (env.GAMECULT_IDUNN_RUNTIME_BUNDLE) { + // Inside an Idunn generation a self-enrolled key is one Idunn does not + // trust, and its private store would land in a state root the next + // generation must share. The descriptor is the only identity there. + throw new Error( + `Idunn runtime bundle is set but the ${PRESENCE_IDENTITY_FD_NAME} descriptor was not passed; refusing to self-enroll` + ); + } return openOrEnrollProviderHealthIdentity(path); } diff --git a/src/runtime-presence.ts b/src/runtime-presence.ts index 8cf0615..e3ed0b1 100644 --- a/src/runtime-presence.ts +++ b/src/runtime-presence.ts @@ -20,7 +20,6 @@ import path from "node:path"; import { createHash, createPrivateKey, type KeyObject } from "node:crypto"; import { readFileSync } from "node:fs"; -import { readFile, writeFile, rename } from "node:fs/promises"; import { decode } from "@msgpack/msgpack"; import { @@ -201,35 +200,25 @@ export function openActivationSigner(env: NodeJS.ProcessEnv = process.env): KeyO } /** - * The publisher sequence must strictly increase for the life of a signer - * identity, because Idunn treats a repeat or a regression as a replay and - * refuses the observation. + * The publisher sequence a process stamps on its presence statements. * - * An in-process counter is therefore not enough: a restart would reset it and - * every statement afterwards would be refused until the count climbed past the - * old high-water mark. It lives beside the target's state instead, and is - * advanced before use. + * Odin compares `publisher_sequence` only against stored presence of the same + * activation, and Idunn mints a fresh activation for every start, continuity + * restarts included. So the sequence need only strictly increase within this + * process; nothing has to survive a restart, and nothing is written to the + * state root. Muninn derives its sequence the same way: the observation clock, + * never below the previous value plus one. */ -export async function nextPublisherSequence(sequencePath: string): Promise { - let current = 0; - try { - const raw = (await readFile(sequencePath, "utf8")).trim(); - const parsed = Number(raw); - if (Number.isSafeInteger(parsed) && parsed > 0) { - current = parsed; - } - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") { - throw error; +export class PublisherSequence { + private last = 0; + + next(observedAtUnixMillis: number): number { + if (!Number.isSafeInteger(observedAtUnixMillis)) { + throw new Error("observedAtUnixMillis is not a safe integer"); } + this.last = Math.max(observedAtUnixMillis, this.last + 1); + return this.last; } - const next = current + 1; - // Written through a temporary file: a torn write here would look like a - // regression and cost the target every future observation. - const temporary = `${sequencePath}.next`; - await writeFile(temporary, `${next}\n`, { mode: 0o600 }); - await rename(temporary, sequencePath); - return next; } export interface PresenceStatementInputs { diff --git a/tests/fixtures/presence-descriptors.ts b/tests/fixtures/presence-descriptors.ts new file mode 100644 index 0000000..0448330 --- /dev/null +++ b/tests/fixtures/presence-descriptors.ts @@ -0,0 +1,72 @@ +/** + * What Idunn hands a generation for runtime presence: a parent-only identity + * store and an activation seed, passed as descriptors named in LISTEN_FDNAMES. + * Every key here is generated for the test or a canary byte pattern. + */ +import { closeSync, openSync } from "node:fs"; +import { chmod, mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; + +import { encode } from "@msgpack/msgpack"; +import { SingleFileMessagePackBackingStore } from "@gamecult/cultcache-ts"; +import { IDUNN_RUNTIME_ACTIVATION_CREDENTIAL_NAME } from "cultnet-ts"; + +import { openOrEnrollProviderHealthIdentity } from "../../src/provider-health-identity.js"; +import type { RuntimeBundleFacts } from "../../src/runtime-presence.js"; + +export const bundleFacts: RuntimeBundleFacts = { + target: "heimdall", + planId: "plan-1", + incarnationId: "incarnation-1", + sealedReleaseId: "release-1", + runtimeId: "runtime-1", + runtimeInstanceId: "instance-1", + expectedProjectionSha256: "sha256-projection", + activationWitnessSha256: "sha256-witness", + activationSignerIdentityId: "activation-signer", + stateSchemaGeneration: null, + stateContractSha256: null, + capabilities: [], + healthContract: "health-contract", +}; + +/** Write both stores into `dir`, readable by any uid (the children run as others). */ +export async function writePresenceFixtures(dir: string): Promise { + await mkdir(dir, { recursive: true }); + await chmod(dir, 0o755); + const enrolled = path.join(dir, "enrolled.cc"); + await openOrEnrollProviderHealthIdentity(enrolled); + const [record] = await new SingleFileMessagePackBackingStore(enrolled).pullAll(); + // Re-wrapped the way cultcache-rs writes a store, which is how Idunn writes it. + await writeFile( + path.join(dir, "runtime-presence-identity.cc"), + encode([ + [ + "gamecult-provider-health-identity", + "gamecult.provider_health_identity.private.v1", + record!.payload, + "2026-09-06T00:00:00.000Z", + "gamecult.provider_health_identity.private.v1", + ], + ]), + { mode: 0o644 } + ); + await writeFile(path.join(dir, "activation-seed"), Buffer.alloc(32, 7), { mode: 0o644 }); +} + +/** Open both descriptors and name them the way systemd's LISTEN_FDNAMES does. */ +export function openPresenceDescriptors(dir: string): { env: NodeJS.ProcessEnv; close: () => void } { + const fds = [ + [openSync(path.join(dir, "runtime-presence-identity.cc"), "r"), "gamecult-runtime-presence-identity"], + [openSync(path.join(dir, "activation-seed"), "r"), IDUNN_RUNTIME_ACTIVATION_CREDENTIAL_NAME], + ] as const; + const names: string[] = []; + for (const [fd, name] of fds) { + while (names.length < fd - 3) names.push(`pad-${names.length}`); + names[fd - 3] = name; + } + return { + env: { LISTEN_FDNAMES: names.join(":") }, + close: () => fds.forEach(([fd]) => closeSync(fd)), + }; +} diff --git a/tests/fixtures/state-root-generation-child.ts b/tests/fixtures/state-root-generation-child.ts new file mode 100644 index 0000000..0e53ade --- /dev/null +++ b/tests/fixtures/state-root-generation-child.ts @@ -0,0 +1,58 @@ +/** + * One Idunn generation, as a process: it runs as the uid and gid its parent + * gave it, under UMask=0007, and does what Heimdall does to a state root on a + * pulse. The parent test spawns two of these under different uids that share + * a group. + * + * argv: + */ +import { readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; + +import { loadConfig } from "../../src/config.js"; +import { PublisherSequence, buildPresenceStatement } from "../../src/runtime-presence.js"; +import { bundleFacts, openPresenceDescriptors } from "./presence-descriptors.js"; +import { createHeimdallRuntimePulse, publishHeimdallVerseState } from "../../src/verse-state.js"; + +const [role, stateRoot, fixtureDir] = process.argv.slice(2) as [string, string, string]; +process.umask(0o007); + +const config = loadConfig({ + GC_ACCESS_BASE_URL: "https://heimdall.example.test", + GC_ACCESS_DATA_ROOT: stateRoot, +}); + +async function publishPresence(): Promise { + const descriptors = openPresenceDescriptors(fixtureDir); + try { + const observedAtUnixMillis = Date.now(); + await buildPresenceStatement({ + bundle: bundleFacts, + state: "warming", + detail: "generation probe", + boundEndpoint: null, + writeLeaseSha256: null, + observedAtUnixMillis, + publisherSequence: new PublisherSequence().next(observedAtUnixMillis), + providerHealthIdentityPath: path.join(stateRoot, "provider-health-identity.cc"), + env: descriptors.env, + }); + } finally { + descriptors.close(); + } +} + +if (role === "next") { + // Generation N+1 reads what N left before it replaces it. + const previous = await readFile(config.cultCachePath); + if (previous.length === 0) throw new Error("previous generation's snapshot is empty"); +} + +await publishHeimdallVerseState(config, createHeimdallRuntimePulse(config)); +await publishPresence(); + +if (role === "first") { + // The 0600 counter the 2026-09-07 generation left behind. Nothing reads it + // now; it stays as the stale fixture the next generation must live beside. + await writeFile(path.join(stateRoot, "runtime-presence-sequence"), "41\n", { mode: 0o600 }); +} diff --git a/tests/presence-identity-descriptor.test.ts b/tests/presence-identity-descriptor.test.ts index 97c5233..731d16e 100644 --- a/tests/presence-identity-descriptor.test.ts +++ b/tests/presence-identity-descriptor.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, readFile, writeFile } from "node:fs/promises"; +import { access, mkdtemp, readFile, writeFile } from "node:fs/promises"; import { openSync, closeSync } from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -112,3 +112,29 @@ describe("presence identity descriptor selection", () => { }); }); }); + +describe("an Idunn generation without the presence identity descriptor", () => { + it("refuses, naming the descriptor, and enrolls nothing", async () => { + const dir = await mkdtemp(path.join(os.tmpdir(), "heimdall-nodescriptor-")); + const identityPath = path.join(dir, "provider-health-identity.cc"); + + await expect( + openProviderHealthIdentity(identityPath, { + GAMECULT_IDUNN_RUNTIME_BUNDLE: "/run/idunn/bundle", + LISTEN_FDNAMES: "some-other-descriptor", + }) + ).rejects.toThrow("gamecult-runtime-presence-identity"); + + await expect(access(identityPath)).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("still self-enrolls outside Idunn", async () => { + if (!machineIdAvailable) return; + const dir = await mkdtemp(path.join(os.tmpdir(), "heimdall-outside-idunn-")); + const identityPath = path.join(dir, "provider-health-identity.cc"); + + await openProviderHealthIdentity(identityPath, {}); + + await expect(access(identityPath)).resolves.toBeUndefined(); + }); +}); diff --git a/tests/publisher-sequence.test.ts b/tests/publisher-sequence.test.ts new file mode 100644 index 0000000..9a4f147 --- /dev/null +++ b/tests/publisher-sequence.test.ts @@ -0,0 +1,87 @@ +import { mkdtemp, readdir, readFile, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { loadConfig } from "../src/config.js"; +import { PublisherSequence, buildPresenceStatement } from "../src/runtime-presence.js"; +import { bundleFacts, openPresenceDescriptors, writePresenceFixtures } from "./fixtures/presence-descriptors.js"; + +const machineIdAvailable = await readFile("/etc/machine-id", "utf8").then( + () => true, + () => false +); + +describe("PublisherSequence", () => { + it("starts at the observed millis", () => { + expect(new PublisherSequence().next(1_700_000_000_000)).toBe(1_700_000_000_000); + }); + + it("strictly increases for equal, earlier and later observations", () => { + const sequence = new PublisherSequence(); + const first = sequence.next(1_700_000_000_000); + const equal = sequence.next(1_700_000_000_000); + const earlier = sequence.next(1_699_999_990_000); + const later = sequence.next(1_700_000_050_000); + expect([first, equal, earlier, later]).toEqual([ + 1_700_000_000_000, + 1_700_000_000_001, + 1_700_000_000_002, + 1_700_000_050_000, + ]); + }); + + it("never falls below the observed millis once the clock moves ahead", () => { + const sequence = new PublisherSequence(); + sequence.next(1_700_000_000_000); + sequence.next(1_700_000_000_000); + sequence.next(1_700_000_000_000); + expect(sequence.next(1_700_000_000_002)).toBe(1_700_000_000_003); + expect(sequence.next(1_700_000_000_010)).toBe(1_700_000_000_010); + }); + + it("refuses a value that is not a safe integer and keeps its place", () => { + const sequence = new PublisherSequence(); + expect(sequence.next(10)).toBe(10); + for (const bad of [Number.NaN, Number.POSITIVE_INFINITY, 1.5, 2 ** 60]) { + expect(() => sequence.next(bad)).toThrow("observedAtUnixMillis"); + } + expect(sequence.next(5)).toBe(11); + }); +}); + +describe.skipIf(!machineIdAvailable)("presence publication and the state root", () => { + it("touches no path under dataRoot", async () => { + const dir = await mkdtemp(path.join(os.tmpdir(), "heimdall-nodataroot-")); + await writePresenceFixtures(path.join(dir, "fixtures")); + // A regular file as dataRoot: opening anything beneath it fails with + // ENOTDIR for every uid, root included. + const dataRoot = path.join(dir, "not-a-directory"); + await writeFile(dataRoot, ""); + const config = loadConfig({ + GC_ACCESS_BASE_URL: "https://heimdall.example.test", + GC_ACCESS_DATA_ROOT: dataRoot, + }); + + const descriptors = openPresenceDescriptors(path.join(dir, "fixtures")); + try { + const observedAtUnixMillis = Date.parse("2026-10-23T12:00:00.000Z"); + const statement = await buildPresenceStatement({ + bundle: bundleFacts, + state: "warming", + detail: "probe", + boundEndpoint: null, + writeLeaseSha256: null, + observedAtUnixMillis, + publisherSequence: new PublisherSequence().next(observedAtUnixMillis), + providerHealthIdentityPath: config.providerHealthIdentityPath, + env: descriptors.env, + }); + expect(statement.target).toBe("heimdall"); + } finally { + descriptors.close(); + } + expect((await readdir(dir)).sort()).toEqual(["fixtures", "not-a-directory"]); + }); +}); diff --git a/tests/state-root-generations.test.ts b/tests/state-root-generations.test.ts new file mode 100644 index 0000000..7632f14 --- /dev/null +++ b/tests/state-root-generations.test.ts @@ -0,0 +1,81 @@ +import { execFile } from "node:child_process"; +import { chmod, chown, mkdir, mkdtemp, readdir, stat } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; + +import { describe, expect, it } from "vitest"; + +import { writePresenceFixtures } from "./fixtures/presence-descriptors.js"; + +const run = promisify(execFile); + +const STATE_GID = 4242; +const GENERATION_N = 61184; +const GENERATION_N_PLUS_1 = 61185; + +// Creating uids and groups needs root; the stopgap's node container runs as root. +const isRoot = process.getuid?.() === 0; + +async function walk(dir: string): Promise { + const found: string[] = []; + for (const entry of await readdir(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + found.push(full); + if (entry.isDirectory()) found.push(...(await walk(full))); + } + return found; +} + +/** + * Idunn gives every generation a fresh DynamicUser uid and one shared group, + * UMask=0007, and a 2770 setgid state root. Generation N+1 runs beside or after + * N and must be able to read and replace everything N left there. + */ +describe.skipIf(!isRoot)("Idunn state root across generations", () => { + it("generation N+1, a different uid in the same group, reads and replaces generation N's state", async () => { + const base = await mkdtemp(path.join(os.tmpdir(), "heimdall-generations-")); + await chmod(base, 0o755); + + // The fixtures stand in for what Idunn passes as descriptors. + const fixtures = path.join(base, "fixtures"); + await writePresenceFixtures(fixtures); + + const stateRoot = path.join(base, "state"); + await mkdir(stateRoot); + await chown(stateRoot, 0, STATE_GID); + await chmod(stateRoot, 0o2770); + + const child = path.join(import.meta.dirname, "fixtures", "state-root-generation-child.ts"); + const generation = (role: "first" | "next", uid: number) => + run(process.execPath, ["--import", "tsx", child, role, stateRoot, fixtures], { + uid, + gid: STATE_GID, + cwd: path.resolve(import.meta.dirname, ".."), + env: { PATH: process.env.PATH ?? "", HOME: fixtures }, + }); + + await generation("first", GENERATION_N); + + const left = await walk(stateRoot); + expect(left.map((entry) => path.relative(stateRoot, entry)).sort()).toEqual([ + "cultcache", + path.join("cultcache", "heimdall.service.cc"), + "runtime-presence-sequence", + ]); + + await generation("next", GENERATION_N_PLUS_1); + + // Generation N+1 replaced the snapshot, and no owner-only file is left + // behind for generation N+2: everything but the stale 0600 fixture is + // readable and writable by the state group. + const snapshot = await stat(path.join(stateRoot, "cultcache", "heimdall.service.cc")); + expect(snapshot.uid).toBe(GENERATION_N_PLUS_1); + expect(snapshot.gid).toBe(STATE_GID); + for (const entry of await walk(stateRoot)) { + if (path.basename(entry) === "runtime-presence-sequence") continue; + expect((await stat(entry)).mode & 0o060, path.relative(stateRoot, entry)).toBe(0o060); + } + expect((await readdir(stateRoot)).sort()).toEqual(["cultcache", "runtime-presence-sequence"]); + }, 60_000); +});