Presence: the publisher sequence is in-process, the state root keeps no owner-only file, an Idunn generation never self-enrolls
nextPublisherSequence read and rewrote <dataRoot>/runtime-presence-sequence at mode 0600, which the next generation (a fresh DynamicUser uid in the shared state group) cannot open. Odin compares publisher_sequence only within one activation and Idunn mints a new activation per start, so PublisherSequence derives it from the observation clock, never below the previous value plus one, as Muninn does. openProviderHealthIdentity under GAMECULT_IDUNN_RUNTIME_BUNDLE now refuses when the presence descriptor is absent instead of enrolling an identity Idunn does not trust. Adds the cross-generation test (root only) and the sequence, no-dataRoot and missing-descriptor tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
25d43d1121
commit
55732a901d
8 changed files with 351 additions and 32 deletions
|
|
@ -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<void> {
|
|||
* work that can only ever return the same answer or fail.
|
||||
*/
|
||||
let runtimeBundleFacts: RuntimeBundleFacts | undefined;
|
||||
const presenceSequence = new PublisherSequence();
|
||||
|
||||
async function publishHealthState(): Promise<void> {
|
||||
const pulse = createHeimdallRuntimePulse(config);
|
||||
|
|
@ -111,9 +111,7 @@ async function publishHealthState(): Promise<void> {
|
|||
// 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,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -128,6 +128,14 @@ export async function openProviderHealthIdentity(
|
|||
): Promise<PrivateIdentity> {
|
||||
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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<number> {
|
||||
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 {
|
||||
|
|
|
|||
72
tests/fixtures/presence-descriptors.ts
vendored
Normal file
72
tests/fixtures/presence-descriptors.ts
vendored
Normal file
|
|
@ -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<void> {
|
||||
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)),
|
||||
};
|
||||
}
|
||||
58
tests/fixtures/state-root-generation-child.ts
vendored
Normal file
58
tests/fixtures/state-root-generation-child.ts
vendored
Normal file
|
|
@ -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: <role: first|next> <stateRoot> <fixtureDir>
|
||||
*/
|
||||
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<void> {
|
||||
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 });
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
87
tests/publisher-sequence.test.ts
Normal file
87
tests/publisher-sequence.test.ts
Normal file
|
|
@ -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"]);
|
||||
});
|
||||
});
|
||||
81
tests/state-root-generations.test.ts
Normal file
81
tests/state-root-generations.test.ts
Normal file
|
|
@ -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<string[]> {
|
||||
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);
|
||||
});
|
||||
Loading…
Reference in a new issue