diff --git a/packages/cli/src/commands/daemon/lifecycle.e2e.test.ts b/packages/cli/src/commands/daemon/lifecycle.e2e.test.ts index 03e4d6bdb5..b1f1f785d5 100644 --- a/packages/cli/src/commands/daemon/lifecycle.e2e.test.ts +++ b/packages/cli/src/commands/daemon/lifecycle.e2e.test.ts @@ -5,6 +5,7 @@ import net from "node:net"; import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; +import { hashDaemonPassword } from "@getpaseo/server/auth"; import { startDaemonInstance, readDaemonInstance } from "@getpaseo/server/daemon-control"; import { expect, test } from "vitest"; import { connectToDaemon } from "../../utils/client.js"; @@ -196,6 +197,26 @@ test("removed flags and ambiguous targets fail before side effects; observation } }, 30_000); +test("status reports the server id of a password-protected daemon to a caller without the password", async () => { + const f = await fixture(); + const home = f.homes[0]!; + try { + await f.configure(home, `127.0.0.1:${await port()}`); + const configPath = path.join(home, "config.json"); + const config = JSON.parse(await readFile(configPath, "utf8")); + config.daemon.auth = { password: await hashDaemonPassword("secret") }; + await writeFile(configPath, JSON.stringify(config)); + await f.ok(["start", "--home", home, "--timeout", "30"]); + const authenticated = await f.liveStatus(home, { PASEO_PASSWORD: "secret" }); + expect(await f.ok(["daemon", "status", "--home", home])).toMatchObject({ + connectedDaemon: "auth_required", + serverId: authenticated.serverId, + }); + } finally { + await f.close(); + } +}, 60_000); + test("an occupied initial or replacement address fails without false readiness or killing its owner", async () => { const f = await fixture(); const [a, b] = f.homes as [string, string]; diff --git a/packages/cli/src/commands/daemon/status.ts b/packages/cli/src/commands/daemon/status.ts index d053ef11ea..14c20534e6 100644 --- a/packages/cli/src/commands/daemon/status.ts +++ b/packages/cli/src/commands/daemon/status.ts @@ -124,6 +124,7 @@ function localStatus(home: string, instance: Awaited { : undefined, restartOnCrash: true, logFile: supervisorLogFile, - onWorkerReady: async ({ listen }) => { - await updatePidLock(paseoHome, { listen }, { ownerPid: process.pid }); + onWorkerReady: async ({ listen, serverId }) => { + await updatePidLock(paseoHome, { listen, serverId }, { ownerPid: process.pid }); }, - onWorkerExit: () => updatePidLock(paseoHome, { listen: null }, { ownerPid: process.pid }), + onWorkerExit: () => + updatePidLock(paseoHome, { listen: null, serverId: null }, { ownerPid: process.pid }), onSupervisorExit: releaseLock, }); requestSupervisorShutdown = supervisor.requestShutdown; diff --git a/packages/server/scripts/supervisor.logging.test.ts b/packages/server/scripts/supervisor.logging.test.ts index 3eb5af9b32..cbb8b51309 100644 --- a/packages/server/scripts/supervisor.logging.test.ts +++ b/packages/server/scripts/supervisor.logging.test.ts @@ -38,7 +38,7 @@ async function runSupervisorFixture(options: { await writeFile( workerPath, - `process.send?.({ type: "paseo:ready", listen: "fixture" });\n${options.workerSource}`, + `process.send?.({ type: "paseo:ready", listen: "fixture", serverId: "srv_fixture" });\n${options.workerSource}`, ); await writeFile( runnerPath, diff --git a/packages/server/scripts/supervisor.readiness.test.ts b/packages/server/scripts/supervisor.readiness.test.ts index 297299d810..bfd02b1402 100644 --- a/packages/server/scripts/supervisor.readiness.test.ts +++ b/packages/server/scripts/supervisor.readiness.test.ts @@ -92,7 +92,7 @@ test("a replacement worker must independently reach ready", async () => { process.on("message", message => { if (message.type === "paseo:graceful-shutdown") process.exit(0); }); - process.send({ type: "paseo:ready", listen: "test-endpoint" }); + process.send({ type: "paseo:ready", listen: "test-endpoint", serverId: "srv_test" }); process.send({ type: "paseo:restart" }); `); expect(result.code).toBe(1); @@ -102,7 +102,7 @@ test("a replacement worker must independently reach ready", async () => { test("exit clears publication after an in-flight ready write", async () => { const result = await runWorker( ` - process.send({ type: "paseo:ready", listen: "test-endpoint" }, () => process.exit(0)); + process.send({ type: "paseo:ready", listen: "test-endpoint", serverId: "srv_test" }, () => process.exit(0)); `, 100, ); diff --git a/packages/server/scripts/supervisor.ts b/packages/server/scripts/supervisor.ts index 5d48491f2a..4798753668 100644 --- a/packages/server/scripts/supervisor.ts +++ b/packages/server/scripts/supervisor.ts @@ -23,6 +23,7 @@ type WorkerLifecycleMessage = | { type: "paseo:ready"; listen: string; + serverId: string; } | { type: "paseo:restart"; @@ -50,7 +51,7 @@ interface SupervisorOptions { args: string[]; env?: NodeJS.ProcessEnv; } | null; - onWorkerReady?: (message: { listen: string }) => Promise | void; + onWorkerReady?: (message: { listen: string; serverId: string }) => Promise | void; onWorkerExit?: () => Promise | void; restartOnCrash?: boolean; onSupervisorExit?: () => Promise | void; @@ -78,11 +79,14 @@ function parseLifecycleMessage(msg: unknown): WorkerLifecycleMessage | null { }; } if (type === "paseo:ready") { - const listen = (msg as { listen?: unknown }).listen; + const { listen, serverId } = msg as { listen?: unknown; serverId?: unknown }; if (typeof listen !== "string" || listen.trim().length === 0) { return null; } - return { type: "paseo:ready", listen }; + if (typeof serverId !== "string" || serverId.trim().length === 0) { + return null; + } + return { type: "paseo:ready", listen, serverId }; } if (type === "paseo:restart") { const reason = (msg as { reason?: unknown }).reason; @@ -289,7 +293,12 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController reachedReady = true; writeLifecycleLog("Worker ready", { listen: lifecycleMessage.listen }); publication = publication - .then(() => options.onWorkerReady?.({ listen: lifecycleMessage.listen })) + .then(() => + options.onWorkerReady?.({ + listen: lifecycleMessage.listen, + serverId: lifecycleMessage.serverId, + }), + ) .catch((error) => { lifecycleFailed = true; const message = error instanceof Error ? error.message : String(error); diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index c90da96eef..4f76b9356a 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -471,6 +471,7 @@ export interface PaseoDaemon { start(): Promise; stop(): Promise; getListenTarget(): ListenTarget | null; + getServerId(): string; } export interface PaseoDaemonDependencies { @@ -1831,6 +1832,7 @@ export async function createPaseoDaemon( start, stop, getListenTarget: () => boundListenTarget, + getServerId: () => serverId, }; } diff --git a/packages/server/src/server/daemon-worker.ts b/packages/server/src/server/daemon-worker.ts index 2adc4ab982..bc5acbc404 100644 --- a/packages/server/src/server/daemon-worker.ts +++ b/packages/server/src/server/daemon-worker.ts @@ -17,6 +17,7 @@ type SupervisorLifecycleMessage = | { type: "paseo:ready"; listen: string; + serverId: string; } | { type: "paseo:restart"; @@ -327,7 +328,11 @@ async function main() { if (!listen) { throw new Error("Daemon did not expose a listen target after startup"); } - sendSupervisorLifecycleMessage({ type: "paseo:ready", listen }); + sendSupervisorLifecycleMessage({ + type: "paseo:ready", + listen, + serverId: daemon.getServerId(), + }); } catch (err) { logger.fatal({ err }, "Daemon failed to start listening"); throw err; diff --git a/packages/server/src/server/pid-lock.ts b/packages/server/src/server/pid-lock.ts index 3005dce9d0..551d5be392 100644 --- a/packages/server/src/server/pid-lock.ts +++ b/packages/server/src/server/pid-lock.ts @@ -11,6 +11,7 @@ export const pidLockInfoSchema = z.object({ hostname: z.string(), uid: z.number(), listen: z.string().nullable(), + serverId: z.string().nullable().optional(), desktopManaged: z.boolean().optional(), heartbeat: z.literal(true).optional(), }); @@ -319,7 +320,7 @@ export function startPidLockHeartbeat( export async function updatePidLock( paseoHome: string, - patch: { listen: string | null }, + patch: { listen: string; serverId: string } | { listen: null; serverId: null }, options?: { ownerPid?: number }, ): Promise { const pidPath = getPidFilePath(paseoHome);