Take the Idunn candidate socket, and keep it out of the advertised URL
Heimdall bound config.port unconditionally, so it could not warm alongside itself: Idunn runs candidate and incumbent at once under distinct UIDs and namespaces, and two generations on 4100 is EADDRINUSE. src/config.ts now prefers GAMECULT_IDUNN_CANDIDATE_BIND, requiring loopback and a usable port, and falls back to HOST/PORT so developer runs and the current plain unit are unaffected. The part worth the care: publicBaseUrl defaulted to `http://${host}:${port}`, and it builds the OAuth callback URL registered with each provider. Left alone, a candidate would have advertised its ephemeral port as that callback -- a URL that stops resolving the moment the candidate is promoted or discarded, breaking logins in a way that would look like a provider problem. Listening address and advertised address are separate authorities, so GC_ACCESS_BASE_URL is now mandatory whenever a candidate bind is present, and startup fails loudly rather than advertising a disappearing port. Six tests cover it, including that regression by name and rejection of non-loopback, zero-port and malformed values. 41 pass across 10 files. Recipe corrected while here: required_environment named HEIMDALL_DATABASE_URL, which does not exist. The real name is GC_ACCESS_DATABASE_URL, confirmed in src/config.ts and in /srv/heimdall/env/service.env; that would have failed at first deploy. HOST and PORT are dropped, since the candidate bind supersedes them under Idunn, and GC_ACCESS_BASE_URL added as required. The recipe's status banner now lists what is done and what is pending rather than saying the contract is unimplemented. The write lease is the remaining blocker: the state slot is process-bound-single-writer and the service does not read the lease, so two generations could both open heimdall.service.cc. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
707da368b1
commit
817d4970b9
3 changed files with 134 additions and 16 deletions
|
|
@ -1,16 +1,17 @@
|
|||
# NOT YET DEPLOYABLE. This recipe is correct and `idunn validate` accepts it,
|
||||
# but the service does not yet implement the runtime contract it declares.
|
||||
# NOT YET DEPLOYABLE, but closer. Status 2026-09-06:
|
||||
#
|
||||
# src/index.ts binds `config.port` (4100, fixed). Idunn's warm-then-cutover
|
||||
# requires a candidate to bind where it is told, via GAMECULT_IDUNN_CANDIDATE_BIND,
|
||||
# because the candidate and the incumbent must run at once under distinct UIDs
|
||||
# and namespaces. A service hardcoded to one port cannot warm alongside itself.
|
||||
# GAMECULT_IDUNN_PROCESS_WRITE_LEASE and GAMECULT_IDUNN_RUNTIME_BUNDLE are
|
||||
# likewise declared and unread; `grep -r GAMECULT_IDUNN src/` returns nothing.
|
||||
# done GAMECULT_IDUNN_CANDIDATE_BIND -- src/config.ts takes the candidate
|
||||
# socket over HOST/PORT, requires loopback and a usable port, and
|
||||
# refuses to start unless GC_ACCESS_BASE_URL is set explicitly, so
|
||||
# an ephemeral candidate port can never become the provider-
|
||||
# registered OAuth callback.
|
||||
# pending GAMECULT_IDUNN_PROCESS_WRITE_LEASE -- declared here because the
|
||||
# state slot below is process-bound-single-writer, and unread by the
|
||||
# service. Two generations could both open heimdall.service.cc.
|
||||
# pending GAMECULT_IDUNN_RUNTIME_BUNDLE -- declared and unread.
|
||||
#
|
||||
# Odin, Ghostlight and CodexConnector each read these. Heimdall publishes Idunn
|
||||
# health already (src/idunn-rudp-health.ts) but stops short of the rest.
|
||||
# Implement the contract before the first `idunn up heimdall`.
|
||||
# Do not `idunn up heimdall` until the write lease is honoured. Health
|
||||
# publication already exists in src/idunn-rudp-health.ts.
|
||||
|
||||
schema = "gamecult.idunn.target_declaration.v1"
|
||||
target = "heimdall"
|
||||
|
|
@ -94,9 +95,8 @@ required_environment = [
|
|||
"GAMECULT_IDUNN_RUNTIME_BUNDLE",
|
||||
"GAMECULT_IDUNN_CANDIDATE_BIND",
|
||||
"GAMECULT_IDUNN_PROCESS_WRITE_LEASE",
|
||||
"HEIMDALL_DATABASE_URL",
|
||||
"HOST",
|
||||
"PORT",
|
||||
"GC_ACCESS_BASE_URL",
|
||||
"GC_ACCESS_DATABASE_URL",
|
||||
]
|
||||
|
||||
[service.health]
|
||||
|
|
|
|||
|
|
@ -77,6 +77,41 @@ function readBoolean(envValue: string | undefined, fallback: boolean): boolean {
|
|||
return fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse `GAMECULT_IDUNN_CANDIDATE_BIND`, the loopback socket Idunn assigns to
|
||||
* a candidate incarnation. Returns undefined when unset, so a developer run
|
||||
* and a plain systemd unit keep using HOST/PORT.
|
||||
*
|
||||
* Loopback is required: the candidate is reached through Idunn's route, never
|
||||
* directly. A non-loopback or zero-port value means the launch inputs are
|
||||
* wrong, and starting anyway would expose an unrouted generation.
|
||||
*/
|
||||
function readIdunnCandidateBind(
|
||||
value: string | undefined
|
||||
): { host: string; port: number } | undefined {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const separator = value.lastIndexOf(":");
|
||||
if (separator <= 0) {
|
||||
throw new Error(`GAMECULT_IDUNN_CANDIDATE_BIND is not host:port: ${value}`);
|
||||
}
|
||||
|
||||
const host = value.slice(0, separator).replace(/^\[|\]$/g, "");
|
||||
const port = Number(value.slice(separator + 1));
|
||||
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
throw new Error(`GAMECULT_IDUNN_CANDIDATE_BIND has no usable port: ${value}`);
|
||||
}
|
||||
|
||||
if (host !== "127.0.0.1" && host !== "::1" && host !== "localhost") {
|
||||
throw new Error(`GAMECULT_IDUNN_CANDIDATE_BIND must be loopback: ${value}`);
|
||||
}
|
||||
|
||||
return { host, port };
|
||||
}
|
||||
|
||||
function readProviderConfig(env: NodeJS.ProcessEnv, provider: Provider): ProviderClientConfig {
|
||||
const prefix = `GC_ACCESS_PROVIDER_${provider.toUpperCase()}`;
|
||||
const config: ProviderClientConfig = {};
|
||||
|
|
@ -106,8 +141,24 @@ function readOptionalString(envValue: string | undefined): string | undefined {
|
|||
export function loadConfig(env: NodeJS.ProcessEnv = process.env): HeimdallConfig {
|
||||
const sourceRoot = fileURLToPath(new URL("../", import.meta.url));
|
||||
const workspaceRoot = path.resolve(sourceRoot, "..");
|
||||
const host = env.HOST ?? "127.0.0.1";
|
||||
const port = readInt(env.PORT, 4100);
|
||||
// Where Heimdall listens and what Heimdall advertises are two different
|
||||
// authorities, and Idunn is the reason they must not be conflated. Under
|
||||
// Idunn a candidate and the incumbent run at once, so the candidate is told
|
||||
// which loopback socket to take; that address is ephemeral by design. The
|
||||
// advertised base URL builds the OAuth callback registered with each
|
||||
// provider, and cannot move.
|
||||
const candidateBind = readIdunnCandidateBind(env.GAMECULT_IDUNN_CANDIDATE_BIND);
|
||||
const host = candidateBind?.host ?? env.HOST ?? "127.0.0.1";
|
||||
const port = candidateBind?.port ?? readInt(env.PORT, 4100);
|
||||
|
||||
if (candidateBind && !env.GC_ACCESS_BASE_URL) {
|
||||
throw new Error(
|
||||
"GC_ACCESS_BASE_URL is required when GAMECULT_IDUNN_CANDIDATE_BIND is set: " +
|
||||
"deriving the public base URL from an Idunn candidate socket would advertise " +
|
||||
"an ephemeral port as the provider-registered OAuth callback."
|
||||
);
|
||||
}
|
||||
|
||||
const publicBaseUrl = trimTrailingSlash(env.GC_ACCESS_BASE_URL ?? `http://${host}:${port}`);
|
||||
const issuer = trimTrailingSlash(env.GC_ACCESS_ISSUER ?? publicBaseUrl);
|
||||
const dataRoot = env.GC_ACCESS_DATA_ROOT ?? path.join(workspaceRoot, ".heimdall-data");
|
||||
|
|
|
|||
67
tests/idunn-candidate-bind.test.ts
Normal file
67
tests/idunn-candidate-bind.test.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { loadConfig } from "../src/config.js";
|
||||
|
||||
describe("Idunn candidate bind", () => {
|
||||
it("falls back to HOST/PORT when Idunn is not the launcher", () => {
|
||||
const config = loadConfig({ HOST: "127.0.0.1", PORT: "4100" });
|
||||
|
||||
expect(config.host).toBe("127.0.0.1");
|
||||
expect(config.port).toBe(4100);
|
||||
});
|
||||
|
||||
it("takes the candidate socket over HOST/PORT", () => {
|
||||
// Idunn owns the listen address for a candidate incarnation, because the
|
||||
// incumbent still holds the ordinary one. HOST/PORT are not consulted.
|
||||
const config = loadConfig({
|
||||
HOST: "127.0.0.1",
|
||||
PORT: "4100",
|
||||
GAMECULT_IDUNN_CANDIDATE_BIND: "127.0.0.1:18833",
|
||||
GC_ACCESS_BASE_URL: "https://heimdall.gamecult.org",
|
||||
});
|
||||
|
||||
expect(config.port).toBe(18833);
|
||||
expect(config.host).toBe("127.0.0.1");
|
||||
});
|
||||
|
||||
it("never advertises the candidate socket as the public base URL", () => {
|
||||
// The regression this guards: publicBaseUrl defaults to host:port, and it
|
||||
// builds the OAuth callback registered with each provider. Deriving it
|
||||
// from an ephemeral candidate port would hand providers a URL that stops
|
||||
// resolving the moment the candidate is promoted or discarded.
|
||||
const config = loadConfig({
|
||||
GAMECULT_IDUNN_CANDIDATE_BIND: "127.0.0.1:18833",
|
||||
GC_ACCESS_BASE_URL: "https://heimdall.gamecult.org",
|
||||
});
|
||||
|
||||
expect(config.publicBaseUrl).toBe("https://heimdall.gamecult.org");
|
||||
expect(config.publicBaseUrl).not.toContain("18833");
|
||||
});
|
||||
|
||||
it("refuses to start under Idunn without an explicit public base URL", () => {
|
||||
expect(() =>
|
||||
loadConfig({ GAMECULT_IDUNN_CANDIDATE_BIND: "127.0.0.1:18833" })
|
||||
).toThrow(/GC_ACCESS_BASE_URL is required/);
|
||||
});
|
||||
|
||||
it("rejects a non-loopback or unusable candidate socket", () => {
|
||||
for (const value of ["0.0.0.0:18833", "10.77.0.1:18833", "127.0.0.1:0", "127.0.0.1", "127.0.0.1:not-a-port"]) {
|
||||
expect(() =>
|
||||
loadConfig({
|
||||
GAMECULT_IDUNN_CANDIDATE_BIND: value,
|
||||
GC_ACCESS_BASE_URL: "https://heimdall.gamecult.org",
|
||||
})
|
||||
).toThrow(/GAMECULT_IDUNN_CANDIDATE_BIND/);
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts a bracketed IPv6 loopback", () => {
|
||||
const config = loadConfig({
|
||||
GAMECULT_IDUNN_CANDIDATE_BIND: "[::1]:18833",
|
||||
GC_ACCESS_BASE_URL: "https://heimdall.gamecult.org",
|
||||
});
|
||||
|
||||
expect(config.host).toBe("::1");
|
||||
expect(config.port).toBe(18833);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue