Use daemon hostnames for initial Hub slugs (#3172)
* fix(hub): send hostname during daemon enrollment Pass the local hostname as daemon enrollment metadata so Hub can assign useful default slugs without coupling daemon authority to the human CLI login. Document the rollout dependency on Hub accepting the optional field. * test(hub): verify enrollment hostname payload
This commit is contained in:
@@ -1111,6 +1111,7 @@ export async function createPaseoDaemon(
|
||||
});
|
||||
const hubRelationships = new HubRelationshipController({
|
||||
paseoHome: config.paseoHome,
|
||||
hostname: getHostname(),
|
||||
serverId,
|
||||
daemonPublicKey: daemonKeyPair.publicKeyB64,
|
||||
logger,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { platform } from "node:os";
|
||||
import { hostname, platform } from "node:os";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import { HubRelationshipHarness } from "./test-utils/relationship-harness.js";
|
||||
|
||||
@@ -88,6 +88,7 @@ describe("Hub relationship", () => {
|
||||
.update(secret ?? "")
|
||||
.digest("base64url"),
|
||||
);
|
||||
expect(enrollment.hostname).toBe(hostname());
|
||||
relationship.completeEnrollment();
|
||||
await connecting.result;
|
||||
await relationship.socketDialed();
|
||||
|
||||
@@ -137,6 +137,7 @@ export interface HubRelationshipRetryPolicy {
|
||||
|
||||
export interface HubRelationshipControllerOptions {
|
||||
paseoHome: string;
|
||||
hostname: string;
|
||||
serverId: string;
|
||||
daemonPublicKey: string;
|
||||
logger: pino.Logger;
|
||||
@@ -324,6 +325,7 @@ export class HubRelationshipController implements HubRelationshipManagement {
|
||||
idempotencyKey: pending.relationship.idempotencyKey,
|
||||
hubOrigin: pending.relationship.hubOrigin,
|
||||
token: pending.enrollment.token,
|
||||
hostname: this.options.hostname,
|
||||
serverId: pending.identity.serverId,
|
||||
daemonPublicKey: pending.identity.daemonPublicKey,
|
||||
credentialVerifier: verifier,
|
||||
|
||||
@@ -61,6 +61,7 @@ test.each([408, 429])("transient enrollment status %s remains retryable", async
|
||||
idempotencyKey: "ceremony-1",
|
||||
hubOrigin,
|
||||
token: "token",
|
||||
hostname: "test-daemon.local",
|
||||
serverId: "server-1",
|
||||
daemonPublicKey: "public-key",
|
||||
credentialVerifier: "verifier",
|
||||
@@ -74,7 +75,10 @@ test.each([408, 429])("transient enrollment status %s remains retryable", async
|
||||
});
|
||||
|
||||
test("enrollment rejects a transport URL that cannot open a WebSocket", async () => {
|
||||
const hubOrigin = await startEnrollmentHub("ftp://hub.test/daemon");
|
||||
let enrolledHostname: string | undefined;
|
||||
const hubOrigin = await startEnrollmentHub("ftp://hub.test/daemon", (enrollment) => {
|
||||
enrolledHostname = enrollment.hostname;
|
||||
});
|
||||
const remote = new DirectHubRelationshipRemote();
|
||||
|
||||
await expect(
|
||||
@@ -83,12 +87,14 @@ test("enrollment rejects a transport URL that cannot open a WebSocket", async ()
|
||||
idempotencyKey: "ceremony-1",
|
||||
hubOrigin,
|
||||
token: "token",
|
||||
hostname: "test-daemon.local",
|
||||
serverId: "server-1",
|
||||
daemonPublicKey: "public-key",
|
||||
credentialVerifier: "verifier",
|
||||
scopes: ["hub.execution.*"],
|
||||
}),
|
||||
).rejects.toThrow("Hub WebSocket URL must use ws or wss");
|
||||
expect(enrolledHostname).toBe("test-daemon.local");
|
||||
});
|
||||
|
||||
test("enrollment rejects a WebSocket URL with a fragment", async () => {
|
||||
@@ -101,6 +107,7 @@ test("enrollment rejects a WebSocket URL with a fragment", async () => {
|
||||
idempotencyKey: "ceremony-1",
|
||||
hubOrigin,
|
||||
token: "token",
|
||||
hostname: "test-daemon.local",
|
||||
serverId: "server-1",
|
||||
daemonPublicKey: "public-key",
|
||||
credentialVerifier: "verifier",
|
||||
@@ -119,6 +126,7 @@ test("enrollment rejects a WebSocket outside the enrolled Hub authority", async
|
||||
idempotencyKey: "ceremony-1",
|
||||
hubOrigin,
|
||||
token: "token",
|
||||
hostname: "test-daemon.local",
|
||||
serverId: "server-1",
|
||||
daemonPublicKey: "public-key",
|
||||
credentialVerifier: "verifier",
|
||||
@@ -138,6 +146,7 @@ test.each(["enrollment", "revocation"])("%s HTTP calls are bounded", async (oper
|
||||
idempotencyKey: "ceremony-1",
|
||||
hubOrigin,
|
||||
token: "token",
|
||||
hostname: "test-daemon.local",
|
||||
serverId: "server-1",
|
||||
daemonPublicKey: "public-key",
|
||||
credentialVerifier: "verifier",
|
||||
@@ -290,11 +299,15 @@ async function startHubReturning(status: number): Promise<string> {
|
||||
return `http://127.0.0.1:${address.port}`;
|
||||
}
|
||||
|
||||
async function startEnrollmentHub(webSocketUrl: string): Promise<string> {
|
||||
async function startEnrollmentHub(
|
||||
webSocketUrl: string,
|
||||
onEnrollment?: (enrollment: { daemonId: string; hostname: string }) => void,
|
||||
): Promise<string> {
|
||||
const server = createServer(async (request, response) => {
|
||||
let body = "";
|
||||
for await (const chunk of request) body += chunk;
|
||||
const enrollment = JSON.parse(body) as { daemonId: string };
|
||||
const enrollment = JSON.parse(body) as { daemonId: string; hostname: string };
|
||||
onEnrollment?.(enrollment);
|
||||
response.writeHead(200, { "content-type": "application/json" }).end(
|
||||
JSON.stringify({
|
||||
daemonId: enrollment.daemonId,
|
||||
@@ -523,6 +536,7 @@ async function connectController(
|
||||
openPaseoHomes.push(paseoHome);
|
||||
const controller = new HubRelationshipController({
|
||||
paseoHome,
|
||||
hostname: "test-daemon.local",
|
||||
serverId: "server-1",
|
||||
daemonPublicKey: "daemon-public-key",
|
||||
logger: pino({ level: "silent" }),
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface HubEnrollment {
|
||||
idempotencyKey: string;
|
||||
hubOrigin: string;
|
||||
token: string;
|
||||
hostname: string;
|
||||
serverId: string;
|
||||
daemonPublicKey: string;
|
||||
credentialVerifier: string;
|
||||
@@ -96,6 +97,7 @@ export class DirectHubRelationshipRemote implements HubRelationshipRemote {
|
||||
body: JSON.stringify({
|
||||
daemonId: input.daemonId,
|
||||
idempotencyKey: input.idempotencyKey,
|
||||
hostname: input.hostname,
|
||||
serverId: input.serverId,
|
||||
daemonPublicKey: input.daemonPublicKey,
|
||||
credentialVerifier: input.credentialVerifier,
|
||||
|
||||
@@ -26,7 +26,9 @@ paseo hub connect
|
||||
|
||||
`connect` uses the active login to request a single-use enrollment token. The daemon exchanges it for its own relationship credential; your CLI login is never stored as daemon authority.
|
||||
|
||||
Each daemon has two identifiers: an immutable generated ID and a friendly slug. Hub normalizes the slug you enter with lowercase words joined by hyphens, so `Build Studio` becomes `build-studio`. The slug is what the dashboard shows and what configuration references.
|
||||
Hub derives the daemon's initial slug from its hostname. If that slug is already used in the organization, Hub adds a short daemon ID suffix. You can rename the daemon later in Hub.
|
||||
|
||||
Each daemon has two identifiers: an immutable generated ID and a friendly slug. Hub normalizes slugs with lowercase words joined by hyphens, so `Build Studio` becomes `build-studio`. The slug is what the dashboard shows and what configuration references.
|
||||
|
||||
You can rename the slug later without changing the daemon ID. Renaming after a configuration is active means updating that configuration.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user