diff --git a/src/index.ts b/src/index.ts index 0bc13c3..4c89a70 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ +import path from "node:path"; import { resolveWriteLease, WriteLeaseNotHeldError } from "./process-write-lease.js"; import { buildApp } from "./app.js"; import { loadConfig } from "./config.js"; @@ -6,16 +7,14 @@ import { publishHeimdallOdinState } from "./odin-publication.js"; import { startHeimdallPrivateCommandPlane } from "./private-command-plane.js"; import type { CultNetOperationServer } from "cultnet-ts"; import { createHeimdallRuntimePulse, buildHeimdallHealthDetail, publishHeimdallVerseState } from "./verse-state.js"; -import { openPresenceAuthority } from "./runtime-presence.js"; +import { + buildPresenceStatement, + nextPublisherSequence, + readRuntimeBundleFacts, + type RuntimeBundleFacts, +} from "./runtime-presence.js"; const config = loadConfig(); -// Under Idunn the launch's presence keys, bundle facts and publisher sequence -// open here, before anything listens. A launch-contract fault throws, uncaught, -// and the process exits non-zero with no listener: Idunn fails Warming in -// seconds rather than waiting on a candidate that can never sign. -const presenceAuthority = config.idunnRuntimeBundlePath - ? await openPresenceAuthority(config) - : undefined; const app = await buildApp({ config }); const versePulseIntervalMs = 60_000; const healthPulseIntervalMs = 10_000; @@ -71,6 +70,13 @@ async function publishVerseState(): Promise { await publishHeimdallOdinState(config, pulse); } +/** + * Read once and keep: the bundle is Idunn's immutable record of this launch and + * cannot change while the process lives, so re-reading it every pulse would be + * work that can only ever return the same answer or fail. + */ +let runtimeBundleFacts: RuntimeBundleFacts | undefined; + async function publishHealthState(): Promise { const pulse = createHeimdallRuntimePulse(config); @@ -88,17 +94,32 @@ async function publishHealthState(): Promise { ? buildHeimdallHealthDetail(config, pulse) : `Heimdall candidate warming; ${lease.reason}`; - if (!presenceAuthority) { + if (!config.idunnRuntimeBundlePath) { // Outside Idunn there is no launch to attest and nothing that admits the // statement; publishing is simply not part of that world. return; } - const state = lease.mayWrite ? "active" : "warming"; - const presence = presenceAuthority.sign(state, detail, Date.parse(pulse.updatedAt)); + runtimeBundleFacts ??= await readRuntimeBundleFacts(config.idunnRuntimeBundlePath); + + const observedAtUnixMillis = Date.parse(pulse.updatedAt); + const presence = await buildPresenceStatement({ + bundle: runtimeBundleFacts, + state: lease.mayWrite ? "active" : "warming", + detail, + boundEndpoint: `http://${config.host}:${config.port}`, + // A warming candidate holds no lease and must not claim one; the contract + // 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"), + ), + providerHealthIdentityPath: config.providerHealthIdentityPath, + }); await publishIdunnRudpHealth(config, { daemonId: config.daemonId, - state, + state: lease.mayWrite ? "active" : "warming", detail, observedAt: pulse.updatedAt, presence, diff --git a/src/provider-health-identity.ts b/src/provider-health-identity.ts index ca89f2c..847d39c 100644 --- a/src/provider-health-identity.ts +++ b/src/provider-health-identity.ts @@ -7,7 +7,7 @@ import { sign, type KeyObject, } from "node:crypto"; -import { closeSync, fstatSync, readSync } from "node:fs"; +import { readFileSync } from "node:fs"; import { chmod, readFile } from "node:fs/promises"; import { SingleFileMessagePackBackingStore, type CultCacheEnvelope } from "@gamecult/cultcache-ts"; import { readPrivateStoreRecord } from "./idunn-store.js"; @@ -71,7 +71,7 @@ export async function openOrEnrollProviderHealthIdentity(path: string): Promise< if (existing.length !== 1) { throw new Error("Provider-health identity store must contain exactly one record."); } - return decodePrivateIdentity(existing[0]!.payload, await readMachineId(), "bin"); + return decodePrivateIdentity(existing[0]!.payload, await readMachineId()); } const PRESENCE_IDENTITY_FD_NAME = "gamecult-runtime-presence-identity"; @@ -124,50 +124,19 @@ export function passedDescriptor( */ export async function openProviderHealthIdentity( path: string, - env: NodeJS.ProcessEnv = process.env, - machineId: () => Promise = readMachineId + env: NodeJS.ProcessEnv = process.env ): 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); } const payload = readPrivateStoreRecord( - readPassedDescriptor(descriptor), + readFileSync(descriptor), "Idunn runtime presence identity" ); - // The descriptor's writer is the Rust owner, whose key fields are integer arrays. - return decodePrivateIdentity(payload, await machineId(), "int-array"); -} - -/** - * Read a passed descriptor whole, from offset 0, and close it. A descriptor is - * a one-shot input: reading it again starts at the offset the last read left, - * which is the end, so the owner reads once at open and keeps what it decoded. - */ -export function readPassedDescriptor(descriptor: number): Buffer { - try { - const size = fstatSync(descriptor).size; - const bytes = Buffer.alloc(size); - let read = 0; - while (read < size) { - const count = readSync(descriptor, bytes, read, size - read, read); - if (count === 0) break; - read += count; - } - return bytes.subarray(0, read); - } finally { - closeSync(descriptor); - } + return decodePrivateIdentity(payload, await readMachineId()); } export function signProviderHealthPayload(identity: PrivateIdentity, payload: Uint8Array): Uint8Array { @@ -199,58 +168,23 @@ export function providerHealthPublicIdentity(identity: PrivateIdentity): { return { identityId: identity.identityId, publicKeyHex: Buffer.from(identity.publicKey).toString("hex") }; } -/** - * Which writer produced the record. The self-enrolling store this process - * writes keeps key fields as MessagePack bin; the Rust owner (cultnet-rs - * service_identity.rs) serialises a Vec without serde_bytes, so its key - * fields are arrays of integers. Each source is read in its own shape and the - * other is refused. - */ -type KeyShape = "bin" | "int-array"; - -function keyBytes(value: unknown, shape: KeyShape, field: string): Uint8Array { - const bytes = - shape === "bin" - ? value instanceof Uint8Array - ? value - : undefined - : Array.isArray(value) && - value.every((byte) => typeof byte === "number" && Number.isInteger(byte) && byte >= 0 && byte <= 255) - ? Uint8Array.from(value as number[]) - : undefined; - if (bytes === undefined || bytes.length !== 32) { - throw new Error( - `Provider-health identity key shape check failed: ${field} is not a 32-byte ${shape === "bin" ? "binary" : "integer-array"} key field.` - ); - } - return bytes; -} - -function decodePrivateIdentity(payload: Uint8Array, machineId: string, shape: KeyShape): PrivateIdentity { +function decodePrivateIdentity(payload: Uint8Array, machineId: string): PrivateIdentity { const value = decode(payload); if (!Array.isArray(value) || value.length !== 10) { - throw new Error("Provider-health identity schema check failed: payload is not a 10-field record."); + throw new Error("Provider-health identity payload is malformed."); } - const [schema, identityId, publicKeyField, protectedSeedField, protector, binding, version] = value; - if (schema !== PRIVATE_SCHEMA) { - throw new Error("Provider-health identity schema check failed: unexpected schema id."); - } - if (typeof identityId !== "string") { - throw new Error("Provider-health identity id check failed: id is not a string."); - } - const publicKey = keyBytes(publicKeyField, shape, "public key"); - const protectedSeed = keyBytes(protectedSeedField, shape, "protected seed"); - if (protector !== "linux_file_mode_machine_id_binding") { - throw new Error("Provider-health identity protector check failed: unexpected protector."); - } - if (binding !== protectorBinding(machineId)) { - throw new Error("Provider-health identity binding check failed: not bound to this host's machine id."); - } - if (version !== "v1") { - throw new Error("Provider-health identity version check failed: unexpected version."); - } - if (identityId !== deriveIdentityId(publicKey)) { - throw new Error("Provider-health identity id check failed: id does not derive from the public key."); + const [schema, identityId, publicKey, protectedSeed, protector, binding, version] = value; + if ( + schema !== PRIVATE_SCHEMA || + typeof identityId !== "string" || + !(publicKey instanceof Uint8Array) || publicKey.length !== 32 || + !(protectedSeed instanceof Uint8Array) || protectedSeed.length !== 32 || + protector !== "linux_file_mode_machine_id_binding" || + binding !== protectorBinding(machineId) || + version !== "v1" || + identityId !== deriveIdentityId(publicKey) + ) { + throw new Error("Provider-health identity does not belong to this host or profile."); } const seed = xorSeed(protectedSeed, binding); const privateKey = privateKeyFromSeed(seed); diff --git a/src/runtime-presence.ts b/src/runtime-presence.ts index 3c1054b..8cf0615 100644 --- a/src/runtime-presence.ts +++ b/src/runtime-presence.ts @@ -19,6 +19,8 @@ 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 { @@ -32,14 +34,11 @@ import { type RuntimePresenceState, } from "cultnet-ts"; -import type { HeimdallConfig } from "./config.js"; import { readIdunnStoreRecord } from "./idunn-store.js"; import { openProviderHealthIdentity, passedDescriptor, - readPassedDescriptor, signWithIdentity, - type PrivateIdentity, } from "./provider-health-identity.js"; const PKCS8_ED25519_SEED_PREFIX = Buffer.from("302e020100300506032b657004220420", "hex"); @@ -179,37 +178,70 @@ export async function readRuntimeBundleFacts(bundlePath: 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; + } + } + 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 { bundle: RuntimeBundleFacts; - identity: PrivateIdentity; - activationKey: KeyObject; state: RuntimePresenceState; detail: string; boundEndpoint: string | null; writeLeaseSha256: string | null; observedAtUnixMillis: number; publisherSequence: number; + providerHealthIdentityPath: string; + env?: NodeJS.ProcessEnv; } export interface PresenceStatement { @@ -218,14 +250,24 @@ export interface PresenceStatement { } /** - * Build and dual-sign one presence statement from keys and facts already open. + * Build and dual-sign one presence statement. * * Returns the target alongside the bytes because the document key must equal * the signed target; Odin refuses the pair when they differ, and that is the * one part of the envelope the transport cannot infer. */ -export function buildPresenceStatement(inputs: PresenceStatementInputs): PresenceStatement { - const { identity } = inputs; +export async function buildPresenceStatement( + inputs: PresenceStatementInputs, +): Promise { + const env = inputs.env ?? process.env; + const activationKey = openActivationSigner(env); + if (activationKey === undefined) { + throw new Error( + "Idunn passed no runtime activation credential, so this process cannot prove it is the " + + "incarnation Idunn started", + ); + } + const identity = await openProviderHealthIdentity(inputs.providerHealthIdentityPath, env); const presence: RuntimePresenceHealth = { target: inputs.bundle.target, @@ -253,7 +295,7 @@ export function buildPresenceStatement(inputs: PresenceStatementInputs): Presenc const proof = runtimePresenceProofPayload(presence); const signature = signWithIdentity(identity, runtimePresenceProviderSigningMessage(proof)); const activationSignature = signWithIdentity( - { ...identity, privateKey: inputs.activationKey }, + { ...identity, privateKey: activationKey }, runtimePresenceActivationSigningMessage(proof), ); @@ -262,72 +304,3 @@ export function buildPresenceStatement(inputs: PresenceStatementInputs): Presenc payload: encodeRuntimePresenceHealth(presence, signature, activationSignature), }; } - -export interface PresenceAuthority { - sign( - state: RuntimePresenceState, - detail: string, - observedAtUnixMillis: number, - ): PresenceStatement; -} - -/** - * Open everything this launch's presence statements stand on, once, before the - * process listens: Idunn's bundle facts, the activation seed and the presence - * identity (each descriptor is read whole and closed), and the one publisher - * sequence. It owns them for the life of the process; a pulse supplies only its - * state, detail and observation time. - * - * A launch-contract fault (a missing or malformed bundle, descriptor or - * identity) throws here, so the process exits before it accepts a request and - * Idunn fails Warming at once, instead of a listener that can never sign. - */ -export async function openPresenceAuthority( - config: Pick, - env: NodeJS.ProcessEnv = process.env, - machineId?: () => Promise, -): Promise { - if (!config.idunnRuntimeBundlePath) { - throw new Error("no Idunn runtime bundle path: presence has no launch to attest"); - } - const bundle = await readRuntimeBundleFacts(config.idunnRuntimeBundlePath); - - const activationDescriptor = passedDescriptor(IDUNN_RUNTIME_ACTIVATION_CREDENTIAL_NAME, env); - if (activationDescriptor === undefined) { - throw new Error( - "Idunn passed no runtime activation credential, so this process cannot prove it is the " + - "incarnation Idunn started", - ); - } - const seed = readPassedDescriptor(activationDescriptor); - if (seed.length !== 32) { - throw new Error("Idunn runtime activation credential is not a 32-byte seed"); - } - const activationKey = createPrivateKey({ - key: Buffer.concat([PKCS8_ED25519_SEED_PREFIX, seed]), - format: "der", - type: "pkcs8", - }); - - const identity = await openProviderHealthIdentity(config.providerHealthIdentityPath, env, machineId); - const boundEndpoint = `http://${config.host}:${config.port}`; - const sequence = new PublisherSequence(); - - return { - sign: (state, detail, observedAtUnixMillis) => - buildPresenceStatement({ - bundle, - identity, - activationKey, - state, - detail, - boundEndpoint, - // A warming candidate holds no lease and must not claim one; the - // contract refuses the pair, and Idunn needs the warming statement to - // promote it. - writeLeaseSha256: null, - observedAtUnixMillis, - publisherSequence: sequence.next(observedAtUnixMillis), - }), - }; -} diff --git a/tests/fixtures/fake-idunn-rudp.ts b/tests/fixtures/fake-idunn-rudp.ts deleted file mode 100644 index e084666..0000000 --- a/tests/fixtures/fake-idunn-rudp.ts +++ /dev/null @@ -1,64 +0,0 @@ -/** - * A stand-in for Idunn's health endpoint: it accepts each RUDP connect, acks - * the frame that follows and keeps every delivered frame. Heimdall opens a - * fresh socket and session per publication, so sessions are kept per remote. - */ -import dgram from "node:dgram"; -import { - CultNetRudpSession, - decodeRudpPacket, - encodeRudpPacket, - type CultNetTransportFrame, -} from "cultnet-ts"; - -export interface FakeIdunnRudp { - port: number; - frames: CultNetTransportFrame[]; - /** Resolves with the nth delivered frame (0-based) once it has arrived. */ - frame(index: number): Promise; - close(): void; -} - -export async function startFakeIdunnRudp(): Promise { - const server = dgram.createSocket("udp4"); - const sessions = new Map(); - const frames: CultNetTransportFrame[] = []; - const waiting: Array<{ index: number; resolve: (frame: CultNetTransportFrame) => void }> = []; - - server.on("message", (wire, remote) => { - const packet = decodeRudpPacket(wire); - const key = `${remote.address}:${remote.port}`; - if (packet.packetType === "connect") { - const session = new CultNetRudpSession({ - connectionId: 0x1d0d0001, - initialSequence: 1, - resendDelayMs: 100, - }); - sessions.set(key, session); - server.send(encodeRudpPacket(session.acceptConnect(packet, Date.now())), remote.port, remote.address); - return; - } - const session = sessions.get(key); - if (!session) return; - const result = session.receive(packet, Date.now()); - if (result.reply) server.send(encodeRudpPacket(result.reply), remote.port, remote.address); - for (const frame of result.delivered) { - frames.push(frame); - for (const waiter of waiting.filter((entry) => entry.index === frames.length - 1)) waiter.resolve(frame); - } - if (result.delivered.length > 0) { - server.send(encodeRudpPacket(session.createAck()), remote.port, remote.address); - } - }); - await new Promise((resolve) => server.bind(0, "127.0.0.1", resolve)); - - return { - port: server.address().port, - frames, - frame: (index) => - frames[index] - ? Promise.resolve(frames[index]!) - : new Promise((resolve) => waiting.push({ index, resolve })), - close: () => server.close(), - }; -} diff --git a/tests/fixtures/idunn-runtime/.gitattributes b/tests/fixtures/idunn-runtime/.gitattributes deleted file mode 100644 index fa1385d..0000000 --- a/tests/fixtures/idunn-runtime/.gitattributes +++ /dev/null @@ -1 +0,0 @@ -* -text diff --git a/tests/fixtures/idunn-runtime/README.md b/tests/fixtures/idunn-runtime/README.md deleted file mode 100644 index 0f26117..0000000 --- a/tests/fixtures/idunn-runtime/README.md +++ /dev/null @@ -1,11 +0,0 @@ -# Idunn runtime fixtures - -Written by the Rust owner, not by Heimdall's tests. Copied byte for byte from CultLib -`217a9480`, `packages/cultnet-ts/test/fixtures/idunn-runtime/web` (`expected.cc`, -`activation.cc`, `activation-key.seed`, `provider-identity.credential`) and that directory's -`machine-id`. CultLib regenerates them with `packages/cultnet-rs/examples/idunn_runtime_fixture.rs` -(its own README has the Yggdrasil command); the provider identity is bound to `machine-id`, so a -test that decodes it supplies that machine id instead of `/etc/machine-id`. - -Every key here is a test key. `provider-identity.credential` is the shape Idunn passes on the -presence-identity descriptor: key fields are 32-integer arrays, which only the Rust owner writes. diff --git a/tests/fixtures/idunn-runtime/activation-key.seed b/tests/fixtures/idunn-runtime/activation-key.seed deleted file mode 100644 index 7b6d37f..0000000 --- a/tests/fixtures/idunn-runtime/activation-key.seed +++ /dev/null @@ -1 +0,0 @@ -¯ÂOd×f¯l*oµ‘ÀFªv‰n�hLûÑpšS´p \ No newline at end of file diff --git a/tests/fixtures/idunn-runtime/activation.cc b/tests/fixtures/idunn-runtime/activation.cc deleted file mode 100644 index 3931baf..0000000 Binary files a/tests/fixtures/idunn-runtime/activation.cc and /dev/null differ diff --git a/tests/fixtures/idunn-runtime/expected.cc b/tests/fixtures/idunn-runtime/expected.cc deleted file mode 100644 index dbf7153..0000000 Binary files a/tests/fixtures/idunn-runtime/expected.cc and /dev/null differ diff --git a/tests/fixtures/idunn-runtime/machine-id b/tests/fixtures/idunn-runtime/machine-id deleted file mode 100644 index a71716f..0000000 --- a/tests/fixtures/idunn-runtime/machine-id +++ /dev/null @@ -1 +0,0 @@ -c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1 \ No newline at end of file diff --git a/tests/fixtures/idunn-runtime/provider-identity.credential b/tests/fixtures/idunn-runtime/provider-identity.credential deleted file mode 100644 index 2e61713..0000000 Binary files a/tests/fixtures/idunn-runtime/provider-identity.credential and /dev/null differ diff --git a/tests/fixtures/presence-descriptors.ts b/tests/fixtures/presence-descriptors.ts deleted file mode 100644 index 8e724f2..0000000 --- a/tests/fixtures/presence-descriptors.ts +++ /dev/null @@ -1,61 +0,0 @@ -/** - * What Idunn hands a generation for runtime presence: the bundle directory, a - * parent-only identity store and an activation seed, the last two passed as - * descriptors named in LISTEN_FDNAMES. The bytes are the Rust owner's, copied - * from CultLib (see idunn-runtime/README.md); every key in them is a test key. - */ -import { closeSync, openSync } from "node:fs"; -import { chmod, copyFile, mkdir, readFile } from "node:fs/promises"; -import path from "node:path"; - -import { IDUNN_RUNTIME_ACTIVATION_CREDENTIAL_NAME } from "cultnet-ts"; - -export const idunnRuntimeFixtures = path.join(import.meta.dirname, "idunn-runtime"); - -const FIXTURE_FILES = [ - "expected.cc", - "activation.cc", - "activation-key.seed", - "provider-identity.credential", - "machine-id", -] as const; - -/** The machine id the fixture identity is bound to, in place of /etc/machine-id. */ -export const fixtureMachineId = async (dir: string = idunnRuntimeFixtures): Promise => - (await readFile(path.join(dir, "machine-id"), "utf8")).trim(); - -/** Copy the fixtures 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); - for (const name of FIXTURE_FILES) { - const target = path.join(dir, name); - await copyFile(path.join(idunnRuntimeFixtures, name), target); - await chmod(target, 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, "provider-identity.credential"), "r"), "gamecult-runtime-presence-identity"], - [openSync(path.join(dir, "activation-key.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(":") }, - // The authority closes each descriptor it reads; this releases only what it did not. - close: () => - fds.forEach(([fd]) => { - try { - closeSync(fd); - } catch { - // already closed by the reader - } - }), - }; -} diff --git a/tests/fixtures/state-root-generation-child.ts b/tests/fixtures/state-root-generation-child.ts deleted file mode 100644 index 932d513..0000000 --- a/tests/fixtures/state-root-generation-child.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** - * 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 { openPresenceAuthority } from "../../src/runtime-presence.js"; -import { fixtureMachineId, 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 authority = await openPresenceAuthority( - { - idunnRuntimeBundlePath: fixtureDir, - host: config.host, - port: config.port, - providerHealthIdentityPath: path.join(stateRoot, "provider-health-identity.cc"), - }, - descriptors.env, - () => fixtureMachineId(fixtureDir), - ); - authority.sign("warming", "generation probe", Date.now()); - } 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/idunn-boot.test.ts b/tests/idunn-boot.test.ts deleted file mode 100644 index a645986..0000000 --- a/tests/idunn-boot.test.ts +++ /dev/null @@ -1,320 +0,0 @@ -import { execFile, spawn, type ChildProcess } from "node:child_process"; -import { createPublicKey, createPrivateKey, verify } from "node:crypto"; -import dgram from "node:dgram"; -import { chmod, copyFile, mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises"; -import { closeSync, openSync } from "node:fs"; -import net from "node:net"; -import os from "node:os"; -import path from "node:path"; -import { promisify } from "node:util"; - -import { decode, encode } from "@msgpack/msgpack"; -import { - RUNTIME_PRESENCE_SLOT, - parseCultNetMessage, - runtimePresenceActivationSigningMessage, - runtimePresenceProviderSigningMessage, -} from "cultnet-ts"; -import { afterAll, beforeAll, describe, expect, it } from "vitest"; - -import { startFakeIdunnRudp, type FakeIdunnRudp } from "./fixtures/fake-idunn-rudp.js"; -import { idunnRuntimeFixtures } from "./fixtures/presence-descriptors.js"; - -const run = promisify(execFile); - -const GENERATION_UID = 61184; -const STATE_GID = 4242; -const ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex"); -const PKCS8_ED25519_SEED_PREFIX = Buffer.from("302e020100300506032b657004220420", "hex"); - -// The launch needs root (to pick the uid and bind a machine-id) and a mount -// namespace (so the fixture machine-id does not replace the host's). The -// stopgap's node container has both; anywhere else the test steps aside. -const canLaunch = - process.getuid?.() === 0 && - (await run("unshare", ["--mount", "--", "true"]).then( - () => true, - () => false - )); - -/** - * The artifact under test. By default the esbuild bundle `pnpm bundle` makes, - * run by this process's node; a runbook proves a sealed release by naming its - * bundle and node here instead. - */ -const bundleOverride = process.env.HEIMDALL_BOOT_BUNDLE; -const nodeOverride = process.env.HEIMDALL_BOOT_NODE; - -async function freePort(): Promise { - return new Promise((resolve, reject) => { - const probe = net.createServer(); - probe.once("error", reject); - probe.listen(0, "127.0.0.1", () => { - const { port } = probe.address() as net.AddressInfo; - probe.close(() => resolve(port)); - }); - }); -} - -interface Launch { - child: ChildProcess; - stderr: () => string; - exited: Promise; - candidatePort: number; -} - -describe.skipIf(!canLaunch)("the bundle booted as Idunn launches it", () => { - let base: string; - let bundle: string; - let node: string; - let idunn: FakeIdunnRudp; - let odin: dgram.Socket; - let odinPort: number; - const launched: ChildProcess[] = []; - - beforeAll(async () => { - base = await mkdtemp(path.join(os.tmpdir(), "heimdall-boot-")); - await chmod(base, 0o755); - if (bundleOverride) { - bundle = bundleOverride; - } else { - await run("corepack", ["pnpm", "bundle"], { cwd: path.join(import.meta.dirname, "..") }); - bundle = path.join(base, "heimdall.mjs"); - await copyFile(path.join(import.meta.dirname, "..", "dist", "heimdall"), bundle); - await chmod(bundle, 0o644); - } - node = nodeOverride ?? process.execPath; - idunn = await startFakeIdunnRudp(); - odin = dgram.createSocket("udp4"); - await new Promise((resolve) => odin.bind(0, "127.0.0.1", resolve)); - odinPort = odin.address().port; - }, 120_000); - - afterAll(() => { - for (const child of launched) child.kill("SIGKILL"); - idunn?.close(); - odin?.close(); - }); - - /** Credential files as Idunn holds them: readable only by the parent. */ - async function credentials(dir: string, identity: Buffer | undefined) { - await mkdir(dir, { recursive: true }); - const seed = path.join(dir, "activation-key.seed"); - await copyFile(path.join(idunnRuntimeFixtures, "activation-key.seed"), seed); - await chmod(seed, 0o400); - let identityFile: string | undefined; - if (identity) { - identityFile = path.join(dir, "provider-identity.credential"); - await writeFile(identityFile, identity, { mode: 0o400 }); - } - return { seed, identityFile }; - } - - /** Start the bundle as the generation unit does, with the fixture machine-id bound over the host's. */ - async function launch(label: string, identity: Buffer | undefined): Promise { - const dir = path.join(base, label); - await mkdir(dir, { recursive: true }); - await chmod(dir, 0o755); - - // Idunn's runtime bundle: the two records, read-only. - const bundleDir = path.join(dir, "bundle"); - await mkdir(bundleDir); - for (const name of ["expected.cc", "activation.cc"]) { - await copyFile(path.join(idunnRuntimeFixtures, name), path.join(bundleDir, name)); - await chmod(path.join(bundleDir, name), 0o444); - } - await chmod(bundleDir, 0o555); - - const machineId = path.join(dir, "machine-id"); - await copyFile(path.join(idunnRuntimeFixtures, "machine-id"), machineId); - await chmod(machineId, 0o444); - - const stateRoot = path.join(dir, "state"); - await mkdir(stateRoot); - await run("chown", [`0:${STATE_GID}`, stateRoot]); - await chmod(stateRoot, 0o2770); - - const heimdall = path.join(dir, "heimdall.mjs"); - await copyFile(bundle, heimdall); - await chmod(heimdall, 0o444); - - const files = await credentials(path.join(dir, "credentials"), identity); - const candidatePort = await freePort(); - - const fds = [openSync(files.seed, "r")]; - const names = ["gamecult-idunn-runtime-activation-key"]; - if (files.identityFile) { - fds.push(openSync(files.identityFile, "r")); - names.push("gamecult-runtime-presence-identity"); - } - - const env = { - PATH: process.env.PATH ?? "", - LISTEN_FDS: String(fds.length), - LISTEN_FDNAMES: names.join(":"), - GAMECULT_IDUNN_CANDIDATE_BIND: `127.0.0.1:${candidatePort}`, - GAMECULT_IDUNN_RUNTIME_BUNDLE: bundleDir, - GAMECULT_IDUNN_PROCESS_WRITE_LEASE: path.join(dir, "process-write-lease.cc"), - GC_ACCESS_BASE_URL: "https://heimdall.test", - GC_ACCESS_ISSUER: "https://heimdall.test", - GC_ACCESS_STORAGE_BACKEND: "memory", - GC_ACCESS_IDUNN_RUDP_HEALTH: `127.0.0.1:${idunn.port}`, - GC_ACCESS_IDUNN_HEALTH_CONTRACT: "heimdall.cultnet-rudp-provider-health", - GC_ACCESS_ODIN_CULTMESH_URI: `rudp://127.0.0.1:${odinPort}`, - CULTMESH_URI_ODIN_RUDP: `rudp://127.0.0.1:${odinPort}`, - GC_ACCESS_SIGNING_PRIVATE_KEY_BOOTSTRAP: "1", - GC_ACCESS_SIGNING_PRIVATE_KEY_PATH: path.join(stateRoot, "signing.pem"), - GC_ACCESS_SIGNING_KEY_ID: "boot-test", - }; - - // uid, gid and umask as Idunn's DynamicUser unit gives them. The fixture - // machine-id is bound only inside this mount namespace. - const script = - `mount --bind ${machineId} /etc/machine-id && ` + - `exec setpriv --reuid ${GENERATION_UID} --regid ${STATE_GID} --clear-groups ` + - `sh -c 'umask 0007; cd ${dir}; exec ${node} heimdall.mjs --state-root ${stateRoot}'`; - const child = spawn("unshare", ["--mount", "--", "sh", "-c", script], { - env, - stdio: ["ignore", "ignore", "pipe", ...fds], - }); - launched.push(child); - fds.forEach((fd) => closeSync(fd)); - - let stderr = ""; - child.stderr!.on("data", (chunk: Buffer) => { - stderr += chunk.toString(); - }); - const exited = new Promise((resolve) => child.once("exit", (code) => resolve(code))); - return { child, stderr: () => stderr, exited, candidatePort }; - } - - async function answers(port: number): Promise { - return fetch(`http://127.0.0.1:${port}/.well-known/jwks.json`).then( - (response) => response.status, - () => undefined - ); - } - - it("signs warming presence on every pulse and answers HTTP", async () => { - const credential = await readFile(path.join(idunnRuntimeFixtures, "provider-identity.credential")); - const generation = await launch("signs", credential); - - // Startup publication, then one per 10 s pulse. A publication fault is - // reported as it is logged rather than after the frames never arrive. - const publicationFault = new Promise((_resolve, reject) => { - const watch = setInterval(() => { - if (generation.stderr().includes(" failed on ")) { - clearInterval(watch); - reject(new Error(`publication failed; stderr: ${generation.stderr()}`)); - } - }, 200); - void idunn.frame(2).then(() => clearInterval(watch)); - }); - const exitedEarly = generation.exited.then((code) => { - throw new Error(`bundle exited ${code}; stderr: ${generation.stderr()}`); - }); - // The first statement is the startup publication, which follows the listen - // directly; the 10 s pulse loop has not yet fired. - await Promise.race([ - idunn.frame(0), - publicationFault, - exitedEarly, - new Promise((_resolve, reject) => - setTimeout(() => reject(new Error(`no presence at startup; stderr: ${generation.stderr()}`)), 8_000) - ), - ]); - await Promise.race([idunn.frame(2), publicationFault, exitedEarly]); - - expect(await answers(generation.candidatePort)).toBe(200); - - // Public keys of the two proofs, from the fixtures' test keys. - const store = decode(credential) as unknown[][]; - const record = decode(store[0]![2] as Uint8Array) as unknown[]; - const providerKey = createPublicKey({ - key: Buffer.concat([ED25519_SPKI_PREFIX, Buffer.from(record[2] as number[])]), - format: "der", - type: "spki", - }); - const activationKey = createPublicKey( - createPrivateKey({ - key: Buffer.concat([ - PKCS8_ED25519_SEED_PREFIX, - await readFile(path.join(idunnRuntimeFixtures, "activation-key.seed")), - ]), - format: "der", - type: "pkcs8", - }) - ); - - let previousSequence = 0; - for (const frame of idunn.frames.slice(0, 3)) { - const message = parseCultNetMessage(decode(frame.payload), "cultnet.schema.v0"); - if (message.schemaVersion !== "cultnet.document_put_raw.v0") throw new Error("not a raw document put"); - const fields = decode(message.document.payload) as unknown[]; - expect(fields[RUNTIME_PRESENCE_SLOT.state]).toBe("warming"); - - const unsigned = [...fields]; - unsigned[RUNTIME_PRESENCE_SLOT.signature] = new Uint8Array(); - unsigned[RUNTIME_PRESENCE_SLOT.activationSignature] = new Uint8Array(); - const proof = encode(unsigned); - expect( - verify( - null, - runtimePresenceProviderSigningMessage(proof), - providerKey, - fields[RUNTIME_PRESENCE_SLOT.signature] as Uint8Array - ) - ).toBe(true); - expect( - verify( - null, - runtimePresenceActivationSigningMessage(proof), - activationKey, - fields[RUNTIME_PRESENCE_SLOT.activationSignature] as Uint8Array - ) - ).toBe(true); - - const sequence = fields[RUNTIME_PRESENCE_SLOT.publisherSequence] as number; - expect(sequence).toBeGreaterThan(previousSequence); - previousSequence = sequence; - } - - expect(generation.stderr()).not.toContain("failed on startup"); - expect(generation.stderr()).not.toContain("failed on interval"); - generation.child.kill("SIGKILL"); - }, 90_000); - - it.each([ - ["the identity descriptor is not passed", undefined, "gamecult-runtime-presence-identity"], - ["the identity is damaged", "damaged", "identity id check"], - ])("exits before listening when %s", async (_name, damage, expected) => { - const credential = await readFile(path.join(idunnRuntimeFixtures, "provider-identity.credential")); - const store = decode(credential) as unknown[][]; - const record = decode(store[0]![2] as Uint8Array) as unknown[]; - const publicKey = [...(record[2] as number[])]; - const original = { publicKeyHex: Buffer.from(publicKey).toString("hex"), id: record[1] as string, binding: record[5] as string }; - let identity: Buffer | undefined; - if (damage) { - publicKey[0] = publicKey[0]! ^ 1; - record[2] = publicKey; - identity = Buffer.from(encode([[store[0]![0], store[0]![1], encode(record), store[0]![3], store[0]![4]]])); - } - const started = Date.now(); - const generation = await launch(damage ? "damaged" : "no-identity", identity); - - const code = await Promise.race([ - generation.exited, - new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 5_000)), - ]); - - expect(code, generation.stderr()).not.toBe("timeout"); - expect(code).not.toBe(0); - expect(Date.now() - started).toBeLessThan(6_000); - expect(await answers(generation.candidatePort)).toBeUndefined(); - expect(generation.stderr()).toContain(expected); - // No byte of the credential: not the key bytes, not the id, not the binding. - expect(generation.stderr()).not.toContain(original.publicKeyHex); - expect(generation.stderr()).not.toContain(original.id.slice(0, 16)); - expect(generation.stderr()).not.toContain(original.binding.slice(-16)); - }, 30_000); -}); diff --git a/tests/idunn-rudp-health.test.ts b/tests/idunn-rudp-health.test.ts index fa40393..66127d4 100644 --- a/tests/idunn-rudp-health.test.ts +++ b/tests/idunn-rudp-health.test.ts @@ -1,12 +1,18 @@ +import dgram from "node:dgram"; import { mkdtemp } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { decode } from "@msgpack/msgpack"; -import { parseCultNetMessage } from "cultnet-ts"; +import { + CultNetRudpSession, + decodeRudpPacket, + encodeRudpPacket, + parseCultNetMessage, + type CultNetTransportFrame, +} from "cultnet-ts"; import { describe, expect, it, vi } from "vitest"; import { loadConfig } from "../src/config.js"; import { publishIdunnRudpHealth } from "../src/idunn-rudp-health.js"; -import { startFakeIdunnRudp } from "./fixtures/fake-idunn-rudp.js"; vi.mock("node:fs/promises", async (importOriginal) => { const original = await importOriginal(); @@ -21,12 +27,37 @@ vi.mock("node:fs/promises", async (importOriginal) => { describe("Heimdall Idunn health publication", () => { it("completes the canonical RUDP handshake before publishing its signed frame", async () => { - const server = await startFakeIdunnRudp(); + const server = dgram.createSocket("udp4"); + const session = new CultNetRudpSession({ + connectionId: 0x1d0d0001, + initialSequence: 1, + resendDelayMs: 100, + }); + let resolveFrame!: (frame: CultNetTransportFrame) => void; + const frameReceived = new Promise((resolve) => { + resolveFrame = resolve; + }); + + server.on("message", (wire, remote) => { + const packet = decodeRudpPacket(wire); + if (packet.packetType === "connect") { + server.send(encodeRudpPacket(session.acceptConnect(packet, Date.now())), remote.port, remote.address); + return; + } + const result = session.receive(packet, Date.now()); + if (result.reply) server.send(encodeRudpPacket(result.reply), remote.port, remote.address); + for (const frame of result.delivered) resolveFrame(frame); + if (result.delivered.length > 0) { + server.send(encodeRudpPacket(session.createAck()), remote.port, remote.address); + } + }); + await new Promise((resolve) => server.bind(0, "127.0.0.1", resolve)); try { const tmp = await mkdtemp(path.join(os.tmpdir(), "heimdall-idunn-rudp-")); + const address = server.address(); const config = loadConfig({ - GC_ACCESS_IDUNN_RUDP_HEALTH: `127.0.0.1:${server.port}`, + GC_ACCESS_IDUNN_RUDP_HEALTH: `127.0.0.1:${address.port}`, GC_ACCESS_PROVIDER_HEALTH_IDENTITY_PATH: path.join(tmp, "provider-health-identity.cc"), }); const observedAt = "2026-08-22T00:45:00.000Z"; @@ -47,7 +78,7 @@ describe("Heimdall Idunn health publication", () => { observedAt, presence, }); - const frame = await server.frame(0); + const frame = await frameReceived; const message = parseCultNetMessage(decode(frame.payload), "cultnet.schema.v0"); expect(frame.channelId).toBe("schema"); diff --git a/tests/presence-identity-descriptor.test.ts b/tests/presence-identity-descriptor.test.ts index d01797b..97c5233 100644 --- a/tests/presence-identity-descriptor.test.ts +++ b/tests/presence-identity-descriptor.test.ts @@ -1,17 +1,18 @@ -import { access, mkdtemp, readFile, writeFile } from "node:fs/promises"; -import { openSync } from "node:fs"; +import { mkdtemp, readFile, writeFile } from "node:fs/promises"; +import { openSync, closeSync } from "node:fs"; import os from "node:os"; import path from "node:path"; -import { decode, encode } from "@msgpack/msgpack"; +import { encode } from "@msgpack/msgpack"; +import { SingleFileMessagePackBackingStore } from "@gamecult/cultcache-ts"; import { describe, expect, it } from "vitest"; import { + openOrEnrollProviderHealthIdentity, openProviderHealthIdentity, passedDescriptor, providerHealthPublicIdentity, } from "../src/provider-health-identity.js"; -import { fixtureMachineId, idunnRuntimeFixtures } from "./fixtures/presence-descriptors.js"; const machineIdAvailable = await readFile("/etc/machine-id", "utf8").then( () => true, @@ -22,79 +23,59 @@ const machineIdAvailable = await readFile("/etc/machine-id", "utf8").then( * The identity is machine-id bound, so enrolment only works where a machine-id * exists. These run on Linux and are skipped elsewhere. */ -/** Name the presence descriptor so it lands on whatever fd open() returned. */ -function presenceEnv(fd: number): NodeJS.ProcessEnv { - // The lookup maps a name index onto 3 + index, so the name list is padded - // until the presence entry lands on the descriptor open() actually returned. - // Assuming it is 3 is how this passes on one machine and reads the wrong - // descriptor on another. - const names = [ - ...Array.from({ length: fd - 3 }, (_, i) => `pad-${i}`), - "gamecult-runtime-presence-identity", - ].join(":"); - return { LISTEN_FDNAMES: names, LISTEN_PID: String(process.pid) }; -} - -describe("presence identity from a passed descriptor", () => { - it("decodes the Rust owner's record, whose key fields are integer arrays", async () => { +describe.skipIf(!machineIdAvailable)("presence identity from a passed descriptor", () => { + it("reads an Idunn-written store, not a cultcache-ts one", async () => { const dir = await mkdtemp(path.join(os.tmpdir(), "heimdall-presence-")); - const fd = openSync(path.join(idunnRuntimeFixtures, "provider-identity.credential"), "r"); - const opened = await openProviderHealthIdentity( - path.join(dir, "would-be-self-enrolled.cc"), - presenceEnv(fd), - fixtureMachineId + // Enrol with the TypeScript client to get a genuine machine-bound payload, + // then re-wrap that payload the way cultcache-rs writes a store: a bare + // array of positional [key, type, payload, stored_at, schema_id] envelopes. + // Idunn writes with the Rust client, and cultcache-ts cannot read that + // layout, so wrapping it here is what makes this test resemble reality. + const tsStore = path.join(dir, "ts-enrolled.cc"); + const enrolled = await openOrEnrollProviderHealthIdentity(tsStore); + const records = await new SingleFileMessagePackBackingStore(tsStore).pullAll(); + expect(records).toHaveLength(1); + + const idunnStore = path.join(dir, "runtime-presence-identity.cc"); + await writeFile( + idunnStore, + encode([ + [ + "gamecult-provider-health-identity", + "gamecult.provider_health_identity.private.v1", + records[0]!.payload, + "2026-09-06T00:00:00.000Z", + "gamecult.provider_health_identity.private.v1", + ], + ]) ); - // The fixture's own public key means it came from the descriptor. A fresh - // self-enrolment would produce a different key, which is exactly the failure - // that makes Idunn refuse this daemon's health and stall promotion. - const store = decode(await readFile(path.join(idunnRuntimeFixtures, "provider-identity.credential"))) as unknown[][]; - const record = decode(store[0]![2] as Uint8Array) as unknown[]; - expect(providerHealthPublicIdentity(opened).publicKeyHex).toBe( - Buffer.from(record[2] as number[]).toString("hex") - ); - await expect(access(path.join(dir, "would-be-self-enrolled.cc"))).rejects.toMatchObject({ code: "ENOENT" }); - }); + const fd = openSync(idunnStore, "r"); + try { + // The lookup maps a name index onto 3 + index, so the name list is padded + // until the presence entry lands on whatever descriptor open() actually + // returned. Assuming it is 3 is how this passes on one machine and reads + // the wrong descriptor on another. + const names = [ + ...Array.from({ length: fd - 3 }, (_, i) => `pad-${i}`), + "gamecult-runtime-presence-identity", + ].join(":"); - it("refuses a bin-shaped record on the descriptor path, naming the key-shape check and no value", async () => { - const dir = await mkdtemp(path.join(os.tmpdir(), "heimdall-presence-bin-")); - const store = decode(await readFile(path.join(idunnRuntimeFixtures, "provider-identity.credential"))) as unknown[][]; - const record = decode(store[0]![2] as Uint8Array) as unknown[]; - // What this process's own enroller writes: the same record with bin key fields. - const binRecord = record.map((field, index) => - index === 2 || index === 3 ? Uint8Array.from(field as number[]) : field - ); - const binStore = path.join(dir, "bin-shaped.cc"); - await writeFile(binStore, encode([[store[0]![0], store[0]![1], encode(binRecord), store[0]![3], store[0]![4]]])); - const fd = openSync(binStore, "r"); + const opened = await openProviderHealthIdentity( + path.join(dir, "would-be-self-enrolled.cc"), + { LISTEN_FDNAMES: names, LISTEN_PID: String(process.pid) } + ); - const failure = await openProviderHealthIdentity(path.join(dir, "x.cc"), presenceEnv(fd), fixtureMachineId).then( - () => undefined, - (error: Error) => error - ); - - expect(failure?.message).toContain("key shape check"); - expect(failure?.message).not.toContain((record[1] as string).slice(0, 16)); - }); - - it.each([ - ["schema", 0, "bogus", "schema check"], - ["protector", 4, "bogus", "protector check"], - ["binding", 5, "bogus", "binding check"], - ["version", 6, "v9", "version check"], - ])("a record failing only the %s check is refused by that check", async (_name, index, replacement, check) => { - const dir = await mkdtemp(path.join(os.tmpdir(), "heimdall-presence-check-")); - const store = decode(await readFile(path.join(idunnRuntimeFixtures, "provider-identity.credential"))) as unknown[][]; - const record = decode(store[0]![2] as Uint8Array) as unknown[]; - record[index] = replacement; - const damaged = path.join(dir, "damaged.cc"); - await writeFile(damaged, encode([[store[0]![0], store[0]![1], encode(record), store[0]![3], store[0]![4]]])); - const fd = openSync(damaged, "r"); - - await expect( - openProviderHealthIdentity(path.join(dir, "x.cc"), presenceEnv(fd), fixtureMachineId) - ).rejects.toThrow(check); + // Same key means it came from the descriptor. A fresh self-enrolment + // would produce a different key -- which is exactly the failure that + // makes Idunn refuse this daemon's health and stall promotion. + expect(providerHealthPublicIdentity(opened).publicKeyHex).toBe( + providerHealthPublicIdentity(enrolled).publicKeyHex + ); + } finally { + closeSync(fd); + } }); }); @@ -131,29 +112,3 @@ 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 deleted file mode 100644 index cfddac2..0000000 --- a/tests/publisher-sequence.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { mkdtemp, readdir, 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, openPresenceAuthority } from "../src/runtime-presence.js"; -import { fixtureMachineId, openPresenceDescriptors, writePresenceFixtures } from "./fixtures/presence-descriptors.js"; - -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("the presence authority", () => { - it("opens once, touches no path under dataRoot, and signs every pulse after its descriptors are gone", async () => { - const dir = await mkdtemp(path.join(os.tmpdir(), "heimdall-nodataroot-")); - const fixtures = path.join(dir, "fixtures"); - await writePresenceFixtures(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(fixtures); - try { - const authority = await openPresenceAuthority( - { ...config, idunnRuntimeBundlePath: fixtures }, - descriptors.env, - fixtureMachineId, - ); - // The descriptors were read whole and closed: a second read would find - // nothing, so signing again proves the authority holds what it decoded. - const observedAtUnixMillis = Date.parse("2026-10-23T12:00:00.000Z"); - const first = authority.sign("warming", "probe", observedAtUnixMillis); - const second = authority.sign("warming", "probe", observedAtUnixMillis); - expect(first.target).toBe("streampixels-web"); - expect(second.target).toBe("streampixels-web"); - expect(Buffer.from(second.payload).equals(Buffer.from(first.payload))).toBe(false); - } 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 deleted file mode 100644 index b8424f3..0000000 --- a/tests/state-root-generations.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -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 { build } from "esbuild"; -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); - - // The generations run as uids that cannot read the checkout, so each runs - // one self-contained bundle built into the world-readable scratch root. - const child = path.join(base, "generation.mjs"); - await build({ - entryPoints: [path.join(import.meta.dirname, "fixtures", "state-root-generation-child.ts")], - bundle: true, - platform: "node", - format: "esm", - target: "node24", - outfile: child, - external: ["pg-native"], - banner: { - js: "import{createRequire}from'node:module';const require=createRequire(import.meta.url);", - }, - logLevel: "silent", - }); - await chmod(child, 0o644); - const generation = (role: "first" | "next", uid: number) => - run(process.execPath, [child, role, stateRoot, fixtures], { - uid, - gid: STATE_GID, - cwd: base, - 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); -});