diff --git a/package.json b/package.json index f68e11d0..0fd75118 100644 --- a/package.json +++ b/package.json @@ -136,10 +136,14 @@ }, "pnpm": { "onlyBuiltDependencies": [ - "@evilmartians/lefthook", "better-sqlite3" ], "ignoredBuiltDependencies": [ + "@evilmartians/lefthook", + "@parcel/watcher", + "esbuild", + "msgpackr-extract", + "protobufjs", "sharp" ] } diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 65783dc3..ee60d835 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,2 +1,8 @@ allowBuilds: + '@evilmartians/lefthook': false + '@parcel/watcher': false better-sqlite3: true + esbuild: false + msgpackr-extract: false + protobufjs: false + sharp: false diff --git a/src/lib/daemon/daemon-lifecycle.ts b/src/lib/daemon/daemon-lifecycle.ts index cb02a40a..0cb21ee8 100644 --- a/src/lib/daemon/daemon-lifecycle.ts +++ b/src/lib/daemon/daemon-lifecycle.ts @@ -368,8 +368,6 @@ async function dispatchTaggedRequest( // Mutable context so lifecycle functions can store server references back. export interface DaemonLifecycleContext { - port: number; - host: string; httpServer: HttpServer | null; /** HTTP-only onboarding server on port+1 (only when TLS is active). */ onboardingServer: HttpServer | null; @@ -386,15 +384,23 @@ export interface DaemonLifecycleContext { router: { handleRequest(req: IncomingMessage, res: ServerResponse): Promise; } | null; - /** When provided, the HTTP server is created as HTTPS with these certs. */ +} + +export interface HttpServerStartConfig { + port: number; + host: string; tls?: { key: Buffer; cert: Buffer }; } // ─── HTTP Server ──────────────────────────────────────────────────────────── /** Create and start the HTTP(S) server, storing it in ctx.httpServer. */ -export function startHttpServer(ctx: DaemonLifecycleContext): Promise { +export function startHttpServer( + ctx: DaemonLifecycleContext, + config: HttpServerStartConfig, +): Promise { return new Promise((resolve, reject) => { + let actualPort = config.port; const handler = (req: IncomingMessage, res: ServerResponse) => { // biome-ignore lint/style/noNonNullAssertion: safe — router set before startHttpServer ctx.router!.handleRequest(req, res).catch((err) => { @@ -406,23 +412,23 @@ export function startHttpServer(ctx: DaemonLifecycleContext): Promise { }); }; - if (ctx.tls) { + if (config.tls) { // ─── TLS mode: protocol detection ───────────────────────────── // A net.Server listens on the port. Each connection's first byte // is peeked: 0x16 (TLS ClientHello) → HTTPS server, otherwise → // plain HTTP redirect to https://. const httpsServer = createHttpsServer( - { key: ctx.tls.key, cert: ctx.tls.cert }, + { key: config.tls.key, cert: config.tls.cert }, handler, ); ctx.upgradeServer = httpsServer; // Lightweight HTTP redirect handler for plain-HTTP connections const httpRedirect = createServer((req, res) => { - const host = req.headers.host ?? `localhost:${ctx.port}`; + const host = req.headers.host ?? `localhost:${actualPort}`; const hostBase = host.replace(/:\d+$/, ""); res.writeHead(301, { - Location: `https://${hostBase}:${ctx.port}${req.url ?? "/"}`, + Location: `https://${hostBase}:${actualPort}${req.url ?? "/"}`, }); res.end(); }); @@ -456,22 +462,28 @@ export function startHttpServer(ctx: DaemonLifecycleContext): Promise { reject(err); }); - ctx.httpServer.listen(ctx.port, ctx.host, () => { + ctx.httpServer.listen(config.port, config.host, () => { // Resolve actual port (important when port 0 is used for OS-assigned ephemeral port) // biome-ignore lint/style/noNonNullAssertion: safe — inside listen callback const addr = ctx.httpServer!.address(); if (addr && typeof addr !== "string") { - ctx.port = addr.port; + actualPort = addr.port; } - resolve(); + resolve(actualPort); }); }); } -/** Gracefully close the HTTP server. */ -export function closeHttpServer(ctx: DaemonLifecycleContext): Promise { +function closeServerHandle(server: HttpServer): Promise { return new Promise((resolve) => { - if (!ctx.httpServer) { + try { + server.closeIdleConnections?.(); + server.closeAllConnections?.(); + } catch { + // Best-effort drain before close. + } + + if (!server.listening) { resolve(); return; } @@ -480,9 +492,37 @@ export function closeHttpServer(ctx: DaemonLifecycleContext): Promise { resolve(); }, SHUTDOWN_TIMEOUT_MS); - ctx.httpServer.close(() => { + try { + server.close(() => { + clearTimeout(timeout); + resolve(); + }); + } catch { clearTimeout(timeout); + resolve(); + } + }); +} + +/** Gracefully close the HTTP server. */ +export function closeHttpServer(ctx: DaemonLifecycleContext): Promise { + return new Promise((resolve) => { + const httpServer = ctx.httpServer; + const upgradeServer = ctx.upgradeServer; + if (!httpServer && !upgradeServer) { + resolve(); + return; + } + + const closes = [ + httpServer ? closeServerHandle(httpServer) : Promise.resolve(), + upgradeServer && upgradeServer !== httpServer + ? closeServerHandle(upgradeServer) + : Promise.resolve(), + ]; + Promise.all(closes).then(() => { ctx.httpServer = null; + ctx.upgradeServer = null; resolve(); }); }); @@ -497,9 +537,16 @@ export interface OnboardingServerDeps { staticDir: string; } +export interface OnboardingServerStartConfig { + /** Main HTTPS port used in redirect/setup URLs. */ + httpsPort: number; + /** Onboarding listen port, usually httpsPort + 1, or 0 for OS assignment. */ + listenPort: number; + host: string; +} + /** - * Start an HTTP-only onboarding server on ctx.port + 1. - * Only call when ctx.tls is present (TLS active). + * Start an HTTP-only onboarding server. * * Serves: /ca/download, /setup (index.html), /api/setup-info, SPA static assets. * Everything else 302-redirects to the HTTPS main server. @@ -507,18 +554,10 @@ export interface OnboardingServerDeps { export function startOnboardingServer( ctx: DaemonLifecycleContext, deps: OnboardingServerDeps, + config: OnboardingServerStartConfig, ): Promise { - // Only start when TLS is active - if (!ctx.tls) { - return Promise.resolve(); - } - - // When ctx.port is 0 (OS-assigned), also use 0 for the onboarding server - // so it gets its own ephemeral port. Otherwise use port+1. - const listenPort = ctx.port === 0 ? 0 : ctx.port + 1; - // Resolved after listen — may differ from listenPort when 0 is used. - let actualPort = listenPort; + let actualPort = config.listenPort; // Pre-read CA cert (if available) so we don't hit disk per request. // DER format preferred (passed in from ensureCerts), PEM as fallback. @@ -595,7 +634,7 @@ export function startOnboardingServer( const host = req.headers.host ?? `localhost:${actualPort}`; const hostBase = host.replace(/:\d+$/, ""); // httpsUrl uses the MAIN port, httpUrl uses the ONBOARDING port - const httpsUrl = `https://${hostBase}:${ctx.port}`; + const httpsUrl = `https://${hostBase}:${config.httpsPort}`; const httpUrl = `http://${hostBase}:${actualPort}`; res.writeHead(200, { "Content-Type": "application/json", @@ -626,7 +665,7 @@ export function startOnboardingServer( const redirectHost = req.headers.host ?? `localhost:${actualPort}`; const redirectHostBase = redirectHost.replace(/:\d+$/, ""); res.writeHead(302, { - Location: `https://${redirectHostBase}:${ctx.port}/setup`, + Location: `https://${redirectHostBase}:${config.httpsPort}/setup`, }); res.end(); } catch (err) { @@ -643,7 +682,7 @@ export function startOnboardingServer( server.on("error", (err: NodeJS.ErrnoException) => { if (err.code === "EADDRINUSE") { log.warn( - `Onboarding server: port ${listenPort} already in use — skipping`, + `Onboarding server: port ${config.listenPort} already in use — skipping`, ); server.close(); resolve(); @@ -652,7 +691,7 @@ export function startOnboardingServer( reject(err); }); - server.listen(listenPort, ctx.host, () => { + server.listen(config.listenPort, config.host, () => { // Resolve actual port (important when listenPort is 0) const addr = server.address(); if (addr && typeof addr !== "string") { @@ -660,7 +699,7 @@ export function startOnboardingServer( } ctx.onboardingServer = server; log.info( - `Onboarding HTTP server listening on ${ctx.host}:${actualPort}`, + `Onboarding HTTP server listening on ${config.host}:${actualPort}`, ); resolve(); }); diff --git a/src/lib/effect/daemon-config-ref.ts b/src/lib/effect/daemon-config-ref.ts index 72cf84af..fe3f530e 100644 --- a/src/lib/effect/daemon-config-ref.ts +++ b/src/lib/effect/daemon-config-ref.ts @@ -44,6 +44,7 @@ export const DaemonConfigRefLive = (initial: DaemonRuntimeConfig) => export const makeDaemonConfigFromOptions = (options: { port?: number; host?: string; + hostExplicit?: boolean; pinHash?: string; tlsEnabled?: boolean; keepAwake?: boolean; @@ -63,6 +64,6 @@ export const makeDaemonConfigFromOptions = (options: { shuttingDown: false, dismissedPaths: new Set(options.dismissedPaths ?? []), startTime: options.startTime ?? Date.now(), - hostExplicit: options.host !== undefined, + hostExplicit: options.hostExplicit ?? false, persistedSessionCounts: new Map(options.persistedSessionCounts ?? []), }); diff --git a/src/lib/effect/daemon-layers.ts b/src/lib/effect/daemon-layers.ts index 70c7af16..aa1f773f 100644 --- a/src/lib/effect/daemon-layers.ts +++ b/src/lib/effect/daemon-layers.ts @@ -11,7 +11,9 @@ import { closeIPCServer, closeOnboardingServer, type DaemonLifecycleContext, + type HttpServerStartConfig, type OnboardingServerDeps, + type OnboardingServerStartConfig, startHttpServer, startIPCServer, startOnboardingServer, @@ -27,7 +29,8 @@ import { AuthManagerFromConfigLive } from "./auth-middleware.js"; import { loadConfig, PersistencePathTag } from "./daemon-config-persistence.js"; import { DaemonConfigRefLive, - makeDaemonConfigFromOptions, + DaemonConfigRefTag, + type DaemonRuntimeConfig, } from "./daemon-config-ref.js"; import { DaemonEventBusLive } from "./daemon-pubsub.js"; import { CrashCounterLive } from "./daemon-startup.js"; @@ -50,7 +53,7 @@ import { StorageMonitorLive, StorageMonitorTag, } from "./storage-monitor-layer.js"; -import { EnsureCertsLive, TlsCertLive } from "./tls-cert-layer.js"; +import { EnsureCertsLive, TlsCertLive, TlsCertTag } from "./tls-cert-layer.js"; import { VersionCheckerLive, VersionCheckerTag, @@ -234,9 +237,36 @@ export const makeRelayCacheLayer = (factory: RelayFactory) => export const makeHttpServerLive = (ctx: DaemonLifecycleContext) => Layer.scopedDiscard( Effect.gen(function* () { - yield* startLifecycleServer("startHttpServer", () => - startHttpServer(ctx), - ); + const configRef = yield* DaemonConfigRefTag; + const tls = yield* TlsCertTag; + const config = yield* Ref.get(configRef); + + const startConfig: HttpServerStartConfig = { + port: config.port, + host: config.host, + }; + if (tls.certs) { + startConfig.tls = { + key: tls.certs.key, + cert: tls.certs.caCertPem + ? Buffer.concat([ + tls.certs.cert, + Buffer.from("\n"), + tls.certs.caCertPem, + ]) + : tls.certs.cert, + }; + } + + const actualPort = yield* Effect.tryPromise({ + try: () => startHttpServer(ctx, startConfig), + catch: (cause) => + new DaemonLifecycleLayerError({ + operation: "startHttpServer", + cause, + }), + }); + yield* Ref.update(configRef, (c) => ({ ...c, port: actualPort })); yield* Effect.addFinalizer(() => closeLifecycleServer(() => closeHttpServer(ctx)), ); @@ -264,9 +294,8 @@ export const makeIpcServerLive = ( ); /** - * Onboarding server layer — starts an HTTP-only onboarding server on port+1 - * when TLS is active (ctx.tls is present). No-ops gracefully when TLS is not - * configured because startOnboardingServer already returns Promise.resolve(). + * Onboarding server layer — starts an HTTP-only onboarding server when TLS is + * active and tears it down gracefully on scope close. */ export const makeOnboardingServerLive = ( ctx: DaemonLifecycleContext, @@ -274,9 +303,31 @@ export const makeOnboardingServerLive = ( ) => Layer.scopedDiscard( Effect.gen(function* () { - yield* startLifecycleServer("startOnboardingServer", () => - startOnboardingServer(ctx, deps), - ); + const configRef = yield* DaemonConfigRefTag; + const tls = yield* TlsCertTag; + const config = yield* Ref.get(configRef); + + if (!tls.certs) return; + + const startConfig: OnboardingServerStartConfig = { + httpsPort: config.port, + listenPort: config.port === 0 ? 0 : config.port + 1, + host: config.host, + }; + const effectiveDeps: OnboardingServerDeps = { + staticDir: deps.staticDir, + caRootPath: tls.caRootPath, + caCertDer: tls.caCertDer, + }; + + yield* Effect.tryPromise({ + try: () => startOnboardingServer(ctx, effectiveDeps, startConfig), + catch: (cause) => + new DaemonLifecycleLayerError({ + operation: "startOnboardingServer", + cause, + }), + }); yield* Effect.addFinalizer(() => closeLifecycleServer(() => closeOnboardingServer(ctx)), ); @@ -322,6 +373,9 @@ export interface DaemonLiveOptions { getStatus: () => DaemonStatus; onboarding: OnboardingServerDeps; + /** Full runtime config snapshot used to seed DaemonConfigRef. */ + initialConfig: DaemonRuntimeConfig; + // Background services — Effect-native config types (all optional for phased migration) keepAwake?: Parameters[0]; versionCheck?: Parameters[0]; @@ -338,10 +392,6 @@ export interface DaemonLiveOptions { configPath?: string; /** Factory for creating relay instances per slug. */ relayFactory?: RelayFactory; - - // AuthManager — initial pinHash for DaemonConfigRef (read reactively by AuthManager) - /** Pre-hashed PIN for authentication, or null if no PIN is set. */ - pinHash?: string | null; } /** @@ -369,13 +419,7 @@ export const makeDaemonLive = (options: DaemonLiveOptions) => { const foundation = Layer.mergeAll( DaemonEventBusLive, PinoLoggerLive, - DaemonConfigRefLive( - makeDaemonConfigFromOptions({ - port: options.ctx.port, - host: options.ctx.host, - ...(options.pinHash != null && { pinHash: options.pinHash }), - }), - ), + DaemonConfigRefLive(options.initialConfig), SignalHandlerLayer, ProcessErrorHandlerLayer, makePidFileLive(configDir, pidPath, socketPath), @@ -426,14 +470,16 @@ export const makeDaemonLive = (options: DaemonLiveOptions) => { ); } - // ── Tier 3: Servers (imperative lifecycle — AP-38 deferred) ──────────── - // Server Layers still take DaemonLifecycleContext params. Converting them - // to read from Context Tags is deferred to AP-38. - const servers = Layer.mergeAll( + // ── Tier 3: Servers (imperative lifecycle) ─────────────────────────── + const httpAndIpc = Layer.mergeAll( makeHttpServerLive(options.ctx), makeIpcServerLive(options.ctx, options.ipcContext, options.getStatus), - makeOnboardingServerLive(options.ctx, options.onboarding), - ).pipe(Layer.provideMerge(registries)); + ); + + const servers = makeOnboardingServerLive( + options.ctx, + options.onboarding, + ).pipe(Layer.provideMerge(httpAndIpc), Layer.provideMerge(registries)); // ── Tier 4: Background services (optional) ──────────────────────────── // When a config is not provided, a no-op stub Layer provides the Tag diff --git a/src/lib/effect/daemon-main.ts b/src/lib/effect/daemon-main.ts index c2b04f3e..c5e3ccd4 100644 --- a/src/lib/effect/daemon-main.ts +++ b/src/lib/effect/daemon-main.ts @@ -34,7 +34,11 @@ import { Supervisor, } from "effect"; import type { DaemonOptions } from "../daemon/daemon-types.js"; -import { DaemonConfigRefTag } from "./daemon-config-ref.js"; +import { + DaemonConfigRefTag, + type DaemonRuntimeConfig, + makeDaemonConfigFromOptions, +} from "./daemon-config-ref.js"; import type { DaemonLiveOptions } from "./daemon-layers.js"; import { makeDaemonLive, @@ -56,7 +60,6 @@ import { ProjectMgmtTag, type RelayCacheTag, } from "./services.js"; -import { TlsCertTag } from "./tls-cert-layer.js"; // ─── SupervisorTag ─────────────────────────────────────────────────────── // Context.Tag for the daemon-wide Supervisor.track instance. @@ -253,7 +256,7 @@ import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { AuthManager } from "../auth.js"; -import { getAllIPs, getTailscaleIP, type TlsCerts } from "../cli/tls.js"; +import { getAllIPs, getTailscaleIP } from "../cli/tls.js"; import { DAEMON_SHUTDOWN_DELAY_MS, DEFAULT_OPENCODE_PORT, @@ -350,6 +353,19 @@ export interface DaemonHandle { readonly registry: import("../daemon/project-registry.js").ProjectRegistry; } +export function resolveRuntimeConfigUpdateSync( + current: DaemonRuntimeConfig, + update: (config: DaemonRuntimeConfig) => DaemonRuntimeConfig, + runRuntimeUpdate: + | (( + update: (config: DaemonRuntimeConfig) => DaemonRuntimeConfig, + ) => DaemonRuntimeConfig) + | null, +): DaemonRuntimeConfig { + if (!runRuntimeUpdate) return update(current); + return runRuntimeUpdate(update); +} + /** * Start the daemon process. Directly orchestrates the startup sequence * that was previously encapsulated in the Daemon class. @@ -395,7 +411,6 @@ export async function startDaemonProcess( let shuttingDown = false; let startTime = Date.now(); let shutdownTimer: ReturnType | null = null; - let tlsCerts: TlsCerts | null = null; let pushManager: PushNotificationManager | null = null; let scanner: PortScanner | null = null; // Layer-managed runtime — set after router setup, used by stop(). @@ -404,6 +419,82 @@ export async function startDaemonProcess( const persistedSessionCounts = new Map(); const dismissedPaths = new Set(); + let runtimeConfigSnapshot = makeDaemonConfigFromOptions({ + port, + host, + hostExplicit: options.host !== undefined, + ...(pinHash != null && { pinHash }), + tlsEnabled, + keepAwake, + ...(keepAwakeCommand != null && { keepAwakeCommand }), + ...(keepAwakeArgs != null && { keepAwakeArgs }), + dismissedPaths: Array.from(dismissedPaths), + startTime, + persistedSessionCounts, + }); + + function syncLegacyConfigLocals(config: DaemonRuntimeConfig): void { + port = config.port; + host = config.host; + pinHash = config.pinHash; + tlsEnabled = config.tlsEnabled; + keepAwake = config.keepAwake; + keepAwakeCommand = config.keepAwakeCommand; + keepAwakeArgs = + config.keepAwakeArgs != null ? [...config.keepAwakeArgs] : undefined; + startTime = config.startTime; + dismissedPaths.clear(); + for (const path of config.dismissedPaths) dismissedPaths.add(path); + persistedSessionCounts.clear(); + for (const [slug, count] of config.persistedSessionCounts) { + persistedSessionCounts.set(slug, count); + } + } + + function readRuntimeConfigSnapshot(): DaemonRuntimeConfig { + if (!daemonRuntime || shuttingDown) return runtimeConfigSnapshot; + try { + runtimeConfigSnapshot = daemonRuntime.runSync( + Effect.gen(function* () { + const ref = yield* DaemonConfigRefTag; + return yield* Ref.get(ref); + }), + ); + syncLegacyConfigLocals(runtimeConfigSnapshot); + } catch { + // During startup/shutdown, keep the last known snapshot. + } + return runtimeConfigSnapshot; + } + + function updateRuntimeConfigSync( + update: (config: DaemonRuntimeConfig) => DaemonRuntimeConfig, + ): void { + const runtime = shuttingDown ? null : daemonRuntime; + try { + runtimeConfigSnapshot = resolveRuntimeConfigUpdateSync( + runtimeConfigSnapshot, + update, + runtime + ? (runtimeUpdate) => + runtime.runSync( + Effect.gen(function* () { + const ref = yield* DaemonConfigRefTag; + yield* Ref.update(ref, runtimeUpdate); + return yield* Ref.get(ref); + }), + ) + : null, + ); + syncLegacyConfigLocals(runtimeConfigSnapshot); + } catch (err) { + log.error( + { err: formatErrorDetail(err) }, + "Runtime config update failed", + ); + throw err; + } + } // ── Core services ───────────────────────────────────────────────────── // AuthManager with reactive pinHash: initially reads from local `pinHash` @@ -411,7 +502,7 @@ export async function startDaemonProcess( // DaemonConfigRef (updated via IPC handlers), and AuthManager reads them // reactively through the getPinHash getter closure. const auth = new AuthManager({ - getPinHash: () => pinHash, + getPinHash: () => readRuntimeConfigSnapshot().pinHash, }); const instanceManager = new InstanceManager(); @@ -422,20 +513,27 @@ export async function startDaemonProcess( let _needsResave = false; function buildConfig(): DaemonConfig { + const cfg = readRuntimeConfigSnapshot(); return { pid: process.pid, - port, - pinHash, - tls: tlsEnabled, + port: cfg.port, + pinHash: cfg.pinHash, + tls: cfg.tlsEnabled, debug: false, - keepAwake, - ...(keepAwakeCommand != null && { keepAwakeCommand }), - ...(keepAwakeArgs != null && { keepAwakeArgs }), + keepAwake: cfg.keepAwake, + ...(cfg.keepAwakeCommand != null && { + keepAwakeCommand: cfg.keepAwakeCommand, + }), + ...(cfg.keepAwakeArgs != null && { keepAwakeArgs: cfg.keepAwakeArgs }), dangerouslySkipPermissions: false, projects: registry.allProjects().map((p) => { const e = registry.get(p.slug); const relay = e?.status === "ready" ? e.relay : undefined; - const sessionCount = relay?.sessionMgr.getLastKnownSessionCount() || 0; + const sessionCount = + relay?.sessionMgr.getLastKnownSessionCount() || + cfg.persistedSessionCounts.get(p.slug) || + persistedSessionCounts.get(p.slug) || + 0; return { path: p.directory, slug: p.slug, @@ -456,8 +554,8 @@ export async function startDaemonProcess( ...(extUrl != null && { url: extUrl }), }; }), - ...(dismissedPaths.size > 0 && { - dismissedPaths: Array.from(dismissedPaths), + ...(cfg.dismissedPaths.size > 0 && { + dismissedPaths: Array.from(cfg.dismissedPaths), }), }; } @@ -485,22 +583,35 @@ export async function startDaemonProcess( } function applyRestartConfig(config: Record): void { - if (typeof config["port"] === "number") port = config["port"]; - if (typeof config["tls"] === "boolean") tlsEnabled = config["tls"]; + updateRuntimeConfigSync((current) => { + let next = current; + if (typeof config["port"] === "number") { + next = { ...next, port: config["port"] }; + } + if (typeof config["tls"] === "boolean") { + next = { ...next, tlsEnabled: config["tls"] }; + } + if (typeof config["pinHash"] === "string" || config["pinHash"] === null) { + next = { ...next, pinHash: config["pinHash"] }; + } + if (typeof config["keepAwake"] === "boolean") { + next = { ...next, keepAwake: config["keepAwake"] }; + } + if (typeof config["keepAwakeCommand"] === "string") { + next = { ...next, keepAwakeCommand: config["keepAwakeCommand"] }; + } + if ( + Array.isArray(config["keepAwakeArgs"]) && + config["keepAwakeArgs"].every((arg) => typeof arg === "string") + ) { + next = { ...next, keepAwakeArgs: [...config["keepAwakeArgs"]] }; + } + return next; + }); if (typeof config["pinHash"] === "string" || config["pinHash"] === null) { pinHash = config["pinHash"]; - // Update DaemonConfigRef so AuthManager sees the new pinHash reactively - if (daemonRuntime) { - daemonRuntime.runPromise( - Effect.gen(function* () { - const ref = yield* DaemonConfigRefTag; - yield* Ref.update(ref, (c) => ({ ...c, pinHash })); - }), - ); - } } if (typeof config["keepAwake"] === "boolean") { - keepAwake = config["keepAwake"]; // AP-22: Forward keepAwake toggle to KeepAwakeTag if (daemonRuntime) { daemonRuntime.runPromise( @@ -522,15 +633,6 @@ export async function startDaemonProcess( ); } } - if (typeof config["keepAwakeCommand"] === "string") { - keepAwakeCommand = config["keepAwakeCommand"]; - } - if ( - Array.isArray(config["keepAwakeArgs"]) && - config["keepAwakeArgs"].every((arg) => typeof arg === "string") - ) { - keepAwakeArgs = config["keepAwakeArgs"]; - } } // ── Registry event listeners ────────────────────────────────────────── @@ -739,6 +841,10 @@ export async function startDaemonProcess( } dir = resolve(dir); dismissedPaths.delete(dir); + updateRuntimeConfigSync((c) => ({ + ...c, + dismissedPaths: new Set(dismissedPaths), + })); const existing = registry.findByDirectory(dir); if (existing) return existing.project; @@ -778,6 +884,10 @@ export async function startDaemonProcess( const entry = registry.get(slug); if (!entry) throw new Error(`Project "${slug}" not found`); dismissedPaths.add(entry.project.directory); + updateRuntimeConfigSync((c) => ({ + ...c, + dismissedPaths: new Set(dismissedPaths), + })); await registry.remove(slug); syncRecentProjects( registry.allProjects().map((p) => ({ @@ -792,6 +902,7 @@ export async function startDaemonProcess( // ── Helper: getStatus ───────────────────────────────────────────────── function getStatus(): import("../daemon/daemon-types.js").DaemonStatus { + const cfg = readRuntimeConfigSnapshot(); const tsIP = getTailscaleIP(); const allIPs = getAllIPs(); const lanIP = allIPs.find((ip) => !ip.startsWith("100.")) ?? null; @@ -802,22 +913,23 @@ export async function startDaemonProcess( const relay = e.status === "ready" ? e.relay : undefined; sessionCount += relay?.sessionMgr.getLastKnownSessionCount() || + cfg.persistedSessionCounts.get(slug) || persistedSessionCounts.get(slug) || 0; } return { ok: true, - uptime: (Date.now() - startTime) / 1000, - port, - host, + uptime: (Date.now() - cfg.startTime) / 1000, + port: cfg.port, + host: cfg.host, ...(tsIP != null && { tailscaleIP: tsIP }), ...(lanIP != null && { lanIP }), projectCount: registry.size, sessionCount, clientCount: ctx.clientCount, - pinEnabled: pinHash !== null, - tlsEnabled, - keepAwake, + pinEnabled: cfg.pinHash !== null, + tlsEnabled: cfg.tlsEnabled, + keepAwake: cfg.keepAwake, projects: Array.from(registry.slugs()).map((slug) => { // biome-ignore lint/style/noNonNullAssertion: slug comes from registry.slugs() so the entry is guaranteed to exist const entry = registry.get(slug)!; @@ -837,6 +949,7 @@ export async function startDaemonProcess( // ── Helper: stop ────────────────────────────────────────────────────── async function stop(): Promise { if (shuttingDown) return; + readRuntimeConfigSnapshot(); shuttingDown = true; if (shutdownTimer) { clearTimeout(shutdownTimer); @@ -853,14 +966,14 @@ export async function startDaemonProcess( // servers (HTTP, IPC, onboarding), signal handlers, error handlers, // PID/socket file cleanup, KeepAwake, VersionChecker, StorageMonitor, // DaemonState, DaemonEventBus. - if (daemonRuntime) await daemonRuntime.dispose(); + const runtime = daemonRuntime; + daemonRuntime = null; + if (runtime) await runtime.dispose(); shuttingDown = false; } // ── Lifecycle context ───────────────────────────────────────────────── const ctx: DaemonLifecycleContext = { - port, - host, httpServer: null, upgradeServer: null, onboardingServer: null, @@ -945,6 +1058,13 @@ export async function startDaemonProcess( if (savedConfig?.keepAwakeArgs) { keepAwakeArgs = savedConfig.keepAwakeArgs; } + updateRuntimeConfigSync((c) => ({ + ...c, + keepAwakeCommand, + keepAwakeArgs, + dismissedPaths: new Set(dismissedPaths), + persistedSessionCounts: new Map(persistedSessionCounts), + })); log.debug(`[startup:${elapsed()}] Rehydration complete`); // ── Probe-and-convert default instance ──────────────────────────────── @@ -1033,8 +1153,9 @@ export async function startDaemonProcess( const ipcContext = { addProject: (dir: string) => addProject(dir), removeProject: (slug: string) => removeProject(slug), - getProjects: () => - registry.allProjects().map((project) => { + getProjects: () => { + const cfg = readRuntimeConfigSnapshot(); + return registry.allProjects().map((project) => { // biome-ignore lint/style/noNonNullAssertion: slug comes from registry.allProjects() so the entry is guaranteed to exist const entry = registry.get(project.slug)!; const relay = entry.status === "ready" ? entry.relay : undefined; @@ -1042,42 +1163,25 @@ export async function startDaemonProcess( ...project, sessions: relay?.sessionMgr.getLastKnownSessionCount() || + cfg.persistedSessionCounts.get(project.slug) || persistedSessionCounts.get(project.slug) || 0, clients: relay?.wsHandler.getClientCount() ?? 0, isProcessing: relay?.isAnySessionProcessing() ?? false, }; - }), + }); + }, setProjectTitle: (slug: string, title: string) => { registry.updateProject(slug, { title }); }, - getPinHash: () => pinHash, + getPinHash: () => readRuntimeConfigSnapshot().pinHash, setPinHash: (hash: string) => { - pinHash = hash; - // AP-24: Update DaemonConfigRef so AuthManager sees the new pinHash - // reactively. AuthManager reads pinHash from DaemonConfigRef. - if (daemonRuntime) { - daemonRuntime.runPromise( - Effect.gen(function* () { - const ref = yield* DaemonConfigRefTag; - yield* Ref.update(ref, (c) => ({ ...c, pinHash: hash })); - }).pipe( - Effect.catchAll((e) => - Effect.logWarning( - "setPinHash: failed to update DaemonConfigRef", - { - error: String(e), - }, - ), - ), - ), - ); - } + updateRuntimeConfigSync((c) => ({ ...c, pinHash: hash })); persistConfig(); }, - getKeepAwake: () => keepAwake, + getKeepAwake: () => readRuntimeConfigSnapshot().keepAwake, setKeepAwake: (enabled: boolean) => { - keepAwake = enabled; + updateRuntimeConfigSync((c) => ({ ...c, keepAwake: enabled })); // AP-22: Delegate to KeepAwakeTag for actual system keep-awake toggling. let supported = false; let active = false; @@ -1105,8 +1209,11 @@ export async function startDaemonProcess( return { supported, active }; }, setKeepAwakeCommand: (command: string, args: string[]) => { - keepAwakeCommand = command; - keepAwakeArgs = args; + updateRuntimeConfigSync((c) => ({ + ...c, + keepAwakeCommand: command, + keepAwakeArgs: args, + })); // AP-23: KeepAwakeTag is scoped and its command is fixed at construction. // To apply a new command, the Layer would need to be rebuilt (Task 11). // For now, persist the new command so it takes effect on next restart. @@ -1171,13 +1278,10 @@ export async function startDaemonProcess( } log.debug(`[startup:${elapsed()}] Push notifications init done`); - // TLS cert loading is now handled by TlsCertLive Layer (via makeDaemonLive). - // After the daemon runtime is built, TLS certs are read back from the - // runtime and used to set ctx.tls for the server lifecycle context. - // ── HTTP request router ─────────────────────────────────────────────── - const getRouterProjects = (): RouterProjectInfo[] => - registry.allProjects().map((project) => { + const getRouterProjects = (): RouterProjectInfo[] => { + const cfg = readRuntimeConfigSnapshot(); + return registry.allProjects().map((project) => { // biome-ignore lint/style/noNonNullAssertion: slug comes from registry.allProjects() so the entry is guaranteed to exist const entry = registry.get(project.slug)!; const relay = entry.status === "ready" ? entry.relay : undefined; @@ -1190,11 +1294,13 @@ export async function startDaemonProcess( clients: relay?.wsHandler.getClientCount() ?? 0, sessions: relay?.sessionMgr.getLastKnownSessionCount() || + cfg.persistedSessionCounts.get(project.slug) || persistedSessionCounts.get(project.slug) || 0, isProcessing: relay?.isAnySessionProcessing() ?? false, } satisfies RouterProjectInfo; }); + }; // biome-ignore lint/suspicious/noExplicitAny: optional daemon providers are merged conditionally. let routerLayer: Layer.Layer = Layer.mergeAll( @@ -1205,7 +1311,10 @@ export async function startDaemonProcess( removeProject: (slug: string) => Effect.tryPromise(() => removeProject(slug)), }), - Layer.succeed(SetupInfoProvider, { port, isTls: tlsEnabled }), + Layer.succeed(SetupInfoProvider, { + getPort: () => readRuntimeConfigSnapshot().port, + getIsTls: () => readRuntimeConfigSnapshot().tlsEnabled, + }), Layer.succeed(HealthProvider, { getHealthResponse: () => getStatus() }), Layer.succeed(ThemeProvider, { loadThemes: loadThemeFiles }), NodeFileSystem.layer, @@ -1245,19 +1354,13 @@ export async function startDaemonProcess( }; // ── Layer-managed daemon lifecycle ──────────────────────────────────── - // Sync port/host into ctx before Layer construction starts servers. - ctx.port = port; - ctx.host = host; - - // TLS cert paths for onboarding are populated after TlsCertLive runs - // (during Layer build). For now, pass nulls — the onboarding server - // reads these at connection time, and they'll be set after runtime init. const onboardingDeps = { caRootPath: null as string | null, caCertDer: null as Buffer | null, staticDir, }; + const initialRuntimeConfig = readRuntimeConfigSnapshot(); const firstProject = registry.allProjects()[0]; const daemonLiveOptions: DaemonLiveOptions = { configDir, @@ -1267,13 +1370,16 @@ export async function startDaemonProcess( ipcContext, getStatus, onboarding: onboardingDeps, + initialConfig: initialRuntimeConfig, // KeepAwake config — pure Effect Layer replaces imperative KeepAwake class. // Always provide config (even empty) so the Layer is created; platform // detection in KeepAwakeLive handles the default command. - keepAwake: keepAwakeCommand + keepAwake: initialRuntimeConfig.keepAwakeCommand ? { - command: keepAwakeCommand, - ...(keepAwakeArgs != null && { args: keepAwakeArgs }), + command: initialRuntimeConfig.keepAwakeCommand, + ...(initialRuntimeConfig.keepAwakeArgs != null && { + args: [...initialRuntimeConfig.keepAwakeArgs], + }), } : {}, // VersionChecker config — pure Effect Layer replaces imperative VersionChecker class. @@ -1313,7 +1419,6 @@ export async function startDaemonProcess( }, // PortScanner: deferred to Task 7 configPath: join(configDir, "daemon.json"), - pinHash, relayFactory: (slug: string) => Effect.tryPromise(() => addProject(slug.replace(/^\/p\//, ""))).pipe( Effect.map((p) => ({ @@ -1342,36 +1447,10 @@ export async function startDaemonProcess( throw err; } - // Read back the actual port (important when port 0 is used) - port = ctx.port; + const postStartupConfig = readRuntimeConfigSnapshot(); log.debug(`[startup:${elapsed()}] Servers started via Layer`); - - // ── TLS certs from Layer ───────────────────────────────────────────── - // TlsCertLive ran during Layer construction. Read back the results to - // update the mutable ctx/variables that legacy code still references. - const tlsService = await daemonRuntime.runPromise(TlsCertTag); - tlsCerts = tlsService.certs; - if (tlsCerts) { - ctx.tls = { - key: tlsCerts.key, - cert: tlsCerts.caCertPem - ? Buffer.concat([tlsCerts.cert, Buffer.from("\n"), tlsCerts.caCertPem]) - : tlsCerts.cert, - }; - } - // Sync tlsEnabled and host from the config Ref (TlsCertLive may have - // updated them during Layer build, e.g. on fallback or host override). - const postTlsConfig = await daemonRuntime.runPromise( - Effect.gen(function* () { - const ref = yield* DaemonConfigRefTag; - return yield* Ref.get(ref); - }), - ); - tlsEnabled = postTlsConfig.tlsEnabled; - host = postTlsConfig.host; - ctx.host = host; log.info( - `[startup:${elapsed()}] TLS certs ${tlsEnabled ? "loaded" : "skipped"}`, + `[startup:${elapsed()}] TLS certs ${postStartupConfig.tlsEnabled ? "loaded" : "skipped"}`, ); // ── WebSocket upgrade routing ───────────────────────────────────────── @@ -1523,6 +1602,10 @@ export async function startDaemonProcess( .then((data: unknown) => { if (Array.isArray(data)) { persistedSessionCounts.set(slug, data.length); + updateRuntimeConfigSync((c) => ({ + ...c, + persistedSessionCounts: new Map(persistedSessionCounts), + })); } }) .catch(() => { @@ -1545,7 +1628,7 @@ export async function startDaemonProcess( // Signal handlers and error handlers are now managed by // SignalHandlerLayer and ProcessErrorHandlerLayer (via makeDaemonLive). - startTime = Date.now(); + updateRuntimeConfigSync((c) => ({ ...c, startTime: Date.now() })); // ── Discover projects (non-blocking) ────────────────────────────────── if (smartDefault) { @@ -1627,7 +1710,7 @@ export async function startDaemonProcess( // ── Return DaemonHandle ─────────────────────────────────────────────── return { get port() { - return port; + return readRuntimeConfigSnapshot().port; }, get onboardingPort() { const server = ctx.onboardingServer; diff --git a/src/lib/relay/relay-stack.ts b/src/lib/relay/relay-stack.ts index 559a105a..38083455 100644 --- a/src/lib/relay/relay-stack.ts +++ b/src/lib/relay/relay-stack.ts @@ -273,8 +273,8 @@ export class EffectRelayServer { Effect.fail(new Error("Project API route not found")), }), Layer.succeed(SetupInfoProvider, { - port: this.actualPort, - isTls: this.protocol === "https", + getPort: () => this.actualPort, + getIsTls: () => this.protocol === "https", }), Layer.succeed(ThemeProvider, { loadThemes: loadThemeFiles }), NodeFileSystem.layer, diff --git a/src/lib/server/effect-http-router.ts b/src/lib/server/effect-http-router.ts index 57f8ad58..e428c99c 100644 --- a/src/lib/server/effect-http-router.ts +++ b/src/lib/server/effect-http-router.ts @@ -102,8 +102,8 @@ export class ThemeProvider extends Context.Tag("ThemeProvider")< export class SetupInfoProvider extends Context.Tag("SetupInfoProvider")< SetupInfoProvider, { - readonly port: number; - readonly isTls: boolean; + readonly getPort: () => number; + readonly getIsTls: () => boolean; } >() {} @@ -340,11 +340,13 @@ const setupInfoHandler = Effect.gen(function* () { } const setup = maybeSetup.value; + const port = setup.getPort(); + const isTls = setup.getIsTls(); const request = yield* HttpServerRequest.HttpServerRequest; - const hostHeader = request.headers["host"] ?? `localhost:${setup.port}`; + const hostHeader = request.headers["host"] ?? `localhost:${port}`; const hostBase = hostHeader.replace(/:\d+$/, ""); - const httpsUrl = `https://${hostBase}:${setup.port}`; - const httpUrl = `http://${hostBase}:${setup.port}`; + const httpsUrl = `https://${hostBase}:${port}`; + const httpUrl = `http://${hostBase}:${port}`; // Check for ?mode=lan query parameter const url = new URL(request.url, `http://${hostHeader}`); @@ -353,7 +355,7 @@ const setupInfoHandler = Effect.gen(function* () { return yield* HttpServerResponse.json({ httpsUrl, httpUrl, - hasCert: setup.isTls, + hasCert: isTls, lanMode, } satisfies SetupInfoResponse); }); diff --git a/test/helpers/tls-cert-fixture.ts b/test/helpers/tls-cert-fixture.ts new file mode 100644 index 00000000..da9ae768 --- /dev/null +++ b/test/helpers/tls-cert-fixture.ts @@ -0,0 +1,43 @@ +import { execFileSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { TlsCerts } from "../../src/lib/cli/tls.js"; + +export function makeTestTlsCerts(): TlsCerts { + const dir = mkdtempSync(join(tmpdir(), "conduit-test-tls-")); + const keyPath = join(dir, "key.pem"); + const certPath = join(dir, "cert.pem"); + + try { + execFileSync( + "openssl", + [ + "req", + "-x509", + "-newkey", + "rsa:2048", + "-keyout", + keyPath, + "-out", + certPath, + "-days", + "1", + "-nodes", + "-subj", + "/CN=localhost", + ], + { stdio: "ignore" }, + ); + + return { + key: readFileSync(keyPath), + cert: readFileSync(certPath), + caRoot: null, + caCertPem: null, + caCertDer: null, + }; + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} diff --git a/test/unit/daemon/daemon-lifecycle-bind.test.ts b/test/unit/daemon/daemon-lifecycle-bind.test.ts new file mode 100644 index 00000000..75df7d0a --- /dev/null +++ b/test/unit/daemon/daemon-lifecycle-bind.test.ts @@ -0,0 +1,179 @@ +import { request as httpRequest } from "node:http"; +import { request as httpsRequest } from "node:https"; +import type { AddressInfo } from "node:net"; +import { describe, expect, it } from "vitest"; +import type { DaemonLifecycleContext } from "../../../src/lib/daemon/daemon-lifecycle.js"; +import { + closeHttpServer, + startHttpServer, +} from "../../../src/lib/daemon/daemon-lifecycle.js"; +import { makeTestTlsCerts } from "../../helpers/tls-cert-fixture.js"; + +const fixtureCerts = makeTestTlsCerts(); + +function makeContext(): DaemonLifecycleContext { + return { + httpServer: null, + upgradeServer: null, + onboardingServer: null, + ipcServer: null, + ipcClients: new Set(), + clientCount: 0, + socketPath: "/tmp/conduit-daemon-lifecycle-bind.sock", + router: { + async handleRequest(_req, res) { + res.writeHead(200, { "Content-Type": "text/plain" }); + res.end("ok"); + }, + }, + }; +} + +function boundAddress(ctx: DaemonLifecycleContext): AddressInfo { + const addr = ctx.httpServer?.address(); + if (!addr || typeof addr === "string") { + throw new Error("HTTP server did not bind to an IP address"); + } + return addr; +} + +function httpsGet( + port: number, + path: string, +): Promise<{ status: number; body: string }> { + return new Promise((resolve, reject) => { + const req = httpsRequest( + { + hostname: "127.0.0.1", + port, + path, + method: "GET", + rejectUnauthorized: false, + }, + (res) => { + let body = ""; + res.setEncoding("utf8"); + res.on("data", (chunk) => { + body += chunk; + }); + res.on("end", () => resolve({ status: res.statusCode ?? 0, body })); + }, + ); + req.on("error", reject); + req.end(); + }); +} + +function httpGet( + port: number, + path: string, +): Promise<{ + status: number; + headers: Record; +}> { + return new Promise((resolve, reject) => { + const req = httpRequest( + { + hostname: "127.0.0.1", + port, + path, + method: "GET", + }, + (res) => { + res.resume(); + res.on("end", () => + resolve({ status: res.statusCode ?? 0, headers: res.headers }), + ); + }, + ); + req.on("error", reject); + req.end(); + }); +} + +describe("startHttpServer bind config", () => { + it("binds to 127.0.0.1 and returns the actual port", async () => { + const ctx = makeContext(); + try { + const actualPort = await startHttpServer(ctx, { + port: 0, + host: "127.0.0.1", + }); + const addr = boundAddress(ctx); + + expect(addr.address).toBe("127.0.0.1"); + expect(addr.port).toBeGreaterThan(0); + expect(actualPort).toBe(addr.port); + } finally { + await closeHttpServer(ctx); + } + }); + + it("binds to 0.0.0.0 and returns the actual port", async () => { + const ctx = makeContext(); + try { + const actualPort = await startHttpServer(ctx, { + port: 0, + host: "0.0.0.0", + }); + const addr = boundAddress(ctx); + + expect(addr.address).toBe("0.0.0.0"); + expect(addr.port).toBeGreaterThan(0); + expect(actualPort).toBe(addr.port); + } finally { + await closeHttpServer(ctx); + } + }); + + it("uses the protocol-detection wrapper in TLS mode and redirects HTTP to the actual port", async () => { + const ctx = makeContext(); + try { + const actualPort = await startHttpServer(ctx, { + port: 0, + host: "127.0.0.1", + tls: { + key: fixtureCerts.key, + cert: fixtureCerts.cert, + }, + }); + + expect(ctx.upgradeServer).not.toBeNull(); + expect(actualPort).toBe(boundAddress(ctx).port); + + const httpsResponse = await httpsGet(actualPort, "/secure"); + expect(httpsResponse.status).toBe(200); + expect(httpsResponse.body).toBe("ok"); + + const httpResponse = await httpGet(actualPort, "/plain"); + expect(httpResponse.status).toBe(301); + expect(httpResponse.headers["location"]).toContain( + `:${actualPort}/plain`, + ); + expect(httpResponse.headers["location"]).not.toContain(":0/plain"); + } finally { + await closeHttpServer(ctx); + } + }); + + it("clears both TLS protocol-detection server handles on close", async () => { + const ctx = makeContext(); + + await startHttpServer(ctx, { + port: 0, + host: "127.0.0.1", + tls: { + key: fixtureCerts.key, + cert: fixtureCerts.cert, + }, + }); + + expect(ctx.httpServer).not.toBeNull(); + expect(ctx.upgradeServer).not.toBeNull(); + + await closeHttpServer(ctx); + + expect(ctx.httpServer).toBeNull(); + expect(ctx.upgradeServer).toBeNull(); + }); +}); diff --git a/test/unit/daemon/daemon-lifecycle-ipc.test.ts b/test/unit/daemon/daemon-lifecycle-ipc.test.ts index 5dcd13e7..d1b8383a 100644 --- a/test/unit/daemon/daemon-lifecycle-ipc.test.ts +++ b/test/unit/daemon/daemon-lifecycle-ipc.test.ts @@ -45,8 +45,6 @@ import type { } from "../../../src/lib/types.js"; const makeContext = (socketPath: string): DaemonLifecycleContext => ({ - port: 0, - host: "127.0.0.1", httpServer: null, onboardingServer: null, upgradeServer: null, diff --git a/test/unit/daemon/daemon-onboarding.test.ts b/test/unit/daemon/daemon-onboarding.test.ts index 7a30db53..ee50b428 100644 --- a/test/unit/daemon/daemon-onboarding.test.ts +++ b/test/unit/daemon/daemon-onboarding.test.ts @@ -7,7 +7,6 @@ // 4. GET /api/setup-info returns JSON with correct ports // 5. Static assets (.js, .css) are served for the SPA // 6. Unknown routes 302-redirect to HTTPS setup URL -// 7. Onboarding server is not started when TLS is not active import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { request as httpRequest } from "node:http"; @@ -15,7 +14,10 @@ import type { AddressInfo } from "node:net"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import type { DaemonLifecycleContext } from "../../../src/lib/daemon/daemon-lifecycle.js"; +import type { + DaemonLifecycleContext, + OnboardingServerStartConfig, +} from "../../../src/lib/daemon/daemon-lifecycle.js"; import { closeOnboardingServer, startOnboardingServer, @@ -122,12 +124,8 @@ describe("startOnboardingServer", () => { cleanTmpDir(tmpDir); }); - function makeCtx( - overrides?: Partial, - ): DaemonLifecycleContext { + function makeCtx(): DaemonLifecycleContext { return { - port: 0, - host: "127.0.0.1", httpServer: null, upgradeServer: null, onboardingServer: null, @@ -136,18 +134,31 @@ describe("startOnboardingServer", () => { clientCount: 0, socketPath: join(tmpDir, "unused.sock"), router: null, - tls: { key: Buffer.from("unused"), cert: Buffer.from("unused") }, + }; + } + + function startConfig( + overrides: Partial = {}, + ): OnboardingServerStartConfig { + return { + httpsPort: 9200, + listenPort: 0, + host: "127.0.0.1", ...overrides, }; } it("GET /ca/download returns CA PEM with correct headers", async () => { const ctx = makeCtx(); - await startOnboardingServer(ctx, { - caRootPath: caPath, - caCertDer: null, - staticDir, - }); + await startOnboardingServer( + ctx, + { + caRootPath: caPath, + caCertDer: null, + staticDir, + }, + startConfig(), + ); const port = getOnboardingPort(ctx); const { status, body, headers } = await httpGet(port, "/ca/download"); @@ -161,11 +172,15 @@ describe("startOnboardingServer", () => { it("GET /ca/download returns 404 when caRootPath is null", async () => { const ctx = makeCtx(); - await startOnboardingServer(ctx, { - caRootPath: null, - caCertDer: null, - staticDir, - }); + await startOnboardingServer( + ctx, + { + caRootPath: null, + caCertDer: null, + staticDir, + }, + startConfig(), + ); const port = getOnboardingPort(ctx); const { status } = await httpGet(port, "/ca/download"); @@ -176,11 +191,15 @@ describe("startOnboardingServer", () => { it("GET /setup returns index.html", async () => { const ctx = makeCtx(); - await startOnboardingServer(ctx, { - caRootPath: caPath, - caCertDer: null, - staticDir, - }); + await startOnboardingServer( + ctx, + { + caRootPath: caPath, + caCertDer: null, + staticDir, + }, + startConfig(), + ); const port = getOnboardingPort(ctx); const { status, body, headers } = await httpGet(port, "/setup"); @@ -193,15 +212,18 @@ describe("startOnboardingServer", () => { it("GET /api/setup-info returns JSON with correct port values", async () => { // Use a known main port so we can verify the URLs use different ports - const ctx = makeCtx({ port: 9200 }); - await startOnboardingServer(ctx, { - caRootPath: caPath, - caCertDer: null, - staticDir, - }); + const ctx = makeCtx(); + await startOnboardingServer( + ctx, + { + caRootPath: caPath, + caCertDer: null, + staticDir, + }, + startConfig(), + ); expect(ctx.onboardingServer).not.toBeNull(); - // The onboarding server listens on port 9201 (ctx.port + 1) - const port = 9201; + const port = getOnboardingPort(ctx); const { status, body } = await httpGet(port, "/api/setup-info"); expect(status).toBe(200); @@ -209,8 +231,8 @@ describe("startOnboardingServer", () => { // httpsUrl should use the main port (9200), not onboarding port (9201) expect(parsed.httpsUrl).toContain(":9200"); expect(parsed.httpsUrl).toMatch(/^https:/); - // httpUrl should use the onboarding port (9201) - expect(parsed.httpUrl).toContain(":9201"); + // httpUrl should use the onboarding port + expect(parsed.httpUrl).toContain(`:${port}`); expect(parsed.httpUrl).toMatch(/^http:/); expect(parsed.hasCert).toBe(true); @@ -219,11 +241,15 @@ describe("startOnboardingServer", () => { it("GET /api/setup-info returns lanMode true when ?mode=lan", async () => { const ctx = makeCtx(); - await startOnboardingServer(ctx, { - caRootPath: caPath, - caCertDer: null, - staticDir, - }); + await startOnboardingServer( + ctx, + { + caRootPath: caPath, + caCertDer: null, + staticDir, + }, + startConfig(), + ); const port = getOnboardingPort(ctx); const { status, body } = await httpGet(port, "/api/setup-info?mode=lan"); @@ -236,11 +262,15 @@ describe("startOnboardingServer", () => { it("serves static assets needed by the SPA", async () => { const ctx = makeCtx(); - await startOnboardingServer(ctx, { - caRootPath: caPath, - caCertDer: null, - staticDir, - }); + await startOnboardingServer( + ctx, + { + caRootPath: caPath, + caCertDer: null, + staticDir, + }, + startConfig(), + ); const port = getOnboardingPort(ctx); const js = await httpGet(port, "/app.abc12345.js"); @@ -257,15 +287,18 @@ describe("startOnboardingServer", () => { }); it("unknown routes 302-redirect to HTTPS setup URL", async () => { - const ctx = makeCtx({ port: 9200 }); - await startOnboardingServer(ctx, { - caRootPath: caPath, - caCertDer: null, - staticDir, - }); + const ctx = makeCtx(); + await startOnboardingServer( + ctx, + { + caRootPath: caPath, + caCertDer: null, + staticDir, + }, + startConfig(), + ); expect(ctx.onboardingServer).not.toBeNull(); - // Onboarding listens on 9201 - const port = 9201; + const port = getOnboardingPort(ctx); // Use raw http.request to avoid following redirects const { status, headers } = await httpGet(port, "/anything-else"); @@ -275,17 +308,4 @@ describe("startOnboardingServer", () => { await closeOnboardingServer(ctx); }); - - it("is NOT started when TLS is not active (no tls in context)", async () => { - const ctx = makeCtx(); - // Remove tls to simulate non-TLS mode - delete ctx.tls; - await startOnboardingServer(ctx, { - caRootPath: null, - caCertDer: null, - staticDir, - }); - // onboardingServer should remain null - expect(ctx.onboardingServer).toBeNull(); - }); }); diff --git a/test/unit/effect/daemon-config-ref.test.ts b/test/unit/effect/daemon-config-ref.test.ts index abbcd622..5c2c2999 100644 --- a/test/unit/effect/daemon-config-ref.test.ts +++ b/test/unit/effect/daemon-config-ref.test.ts @@ -66,6 +66,7 @@ describe("DaemonConfigRef", () => { keepAwake: true, pinHash: "abc123", host: "0.0.0.0", + hostExplicit: true, }), ), ), @@ -73,6 +74,23 @@ describe("DaemonConfigRef", () => { ), ); + it("makeDaemonConfigFromOptions carries tlsEnabled and explicit hostExplicit", () => { + const c1 = makeDaemonConfigFromOptions({ tlsEnabled: true }); + expect(c1.tlsEnabled).toBe(true); + expect(c1.hostExplicit).toBe(false); + + const c2 = makeDaemonConfigFromOptions({ + tlsEnabled: false, + hostExplicit: true, + host: "127.0.0.1", + }); + expect(c2.tlsEnabled).toBe(false); + expect(c2.hostExplicit).toBe(true); + + const c3 = makeDaemonConfigFromOptions({ host: "0.0.0.0" }); + expect(c3.hostExplicit).toBe(false); + }); + it.effect("dismissedPaths is an independent Set per instance", () => Effect.gen(function* () { const ref = yield* DaemonConfigRefTag; diff --git a/test/unit/effect/daemon-main-getstatus.test.ts b/test/unit/effect/daemon-main-getstatus.test.ts new file mode 100644 index 00000000..7825bac1 --- /dev/null +++ b/test/unit/effect/daemon-main-getstatus.test.ts @@ -0,0 +1,293 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { createConnection } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { TlsCerts } from "../../../src/lib/cli/tls.js"; +import { + loadDaemonConfig, + saveDaemonConfig, +} from "../../../src/lib/daemon/config-persistence.js"; +import { makeDaemonConfigFromOptions } from "../../../src/lib/effect/daemon-config-ref.js"; +import type { DaemonHandle } from "../../../src/lib/effect/daemon-main.js"; +import { resolveRuntimeConfigUpdateSync } from "../../../src/lib/effect/daemon-main.js"; +import { makeTestTlsCerts } from "../../helpers/tls-cert-fixture.js"; + +const ensureCertsMock = vi.hoisted(() => vi.fn()); + +vi.mock("../../../src/lib/cli/tls.js", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + ensureCerts: ensureCertsMock, + getTailscaleIP: vi.fn(() => null), + getAllIPs: vi.fn(() => []), + }; +}); + +const fixtureCerts: TlsCerts = makeTestTlsCerts(); + +async function waitForPersistedConfig( + configDir: string, + predicate: ( + config: NonNullable>, + ) => boolean, +): Promise>> { + const deadline = Date.now() + 1_000; + let lastConfig: ReturnType = null; + + while (Date.now() < deadline) { + lastConfig = loadDaemonConfig(configDir); + if (lastConfig && predicate(lastConfig)) return lastConfig; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + + throw new Error( + `Timed out waiting for persisted config: ${JSON.stringify(lastConfig)}`, + ); +} + +async function sendRestartConfig( + socketPath: string, + config: Record, +): Promise> { + return new Promise((resolve, reject) => { + const client = createConnection(socketPath); + let buffer = ""; + + client.on("connect", () => { + client.write( + `${JSON.stringify({ _tag: "RestartWithConfig", config })}\n`, + ); + }); + client.on("data", (chunk: Buffer) => { + buffer += chunk.toString("utf8"); + if (!buffer.includes("\n")) return; + const line = buffer.slice(0, buffer.indexOf("\n")); + client.end(); + resolve(JSON.parse(line) as Record); + }); + client.on("error", reject); + }); +} + +describe("daemon main runtime config status", () => { + let tmpDir: string; + let daemon: DaemonHandle | null; + + beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), "conduit-daemon-main-status-")); + daemon = null; + vi.clearAllMocks(); + ensureCertsMock.mockResolvedValue(fixtureCerts); + }); + + afterEach(async () => { + try { + await daemon?.stop(); + } catch { + // ignore failed startup or restart shutdown races + } + await rm(tmpDir, { recursive: true, force: true }); + }); + + it("reports TLS success with 0.0.0.0 host and the actual bound port", async () => { + const { startDaemonProcess } = await import( + "../../../src/lib/effect/daemon-main.js" + ); + + daemon = await startDaemonProcess({ + configDir: tmpDir, + socketPath: join(tmpDir, "relay.sock"), + pidPath: join(tmpDir, "daemon.pid"), + staticDir: tmpDir, + port: 0, + tlsEnabled: true, + smartDefault: false, + }); + + const status = daemon.getStatus(); + expect(status.tlsEnabled).toBe(true); + expect(status.host).toBe("0.0.0.0"); + expect(status.port).toBeGreaterThan(0); + expect(daemon.port).toBe(status.port); + }); + + it("reports TLS disabled with the explicit loopback host", async () => { + const { startDaemonProcess } = await import( + "../../../src/lib/effect/daemon-main.js" + ); + + daemon = await startDaemonProcess({ + configDir: tmpDir, + socketPath: join(tmpDir, "relay.sock"), + pidPath: join(tmpDir, "daemon.pid"), + staticDir: tmpDir, + port: 0, + host: "127.0.0.1", + tlsEnabled: false, + smartDefault: false, + }); + + const status = daemon.getStatus(); + expect(status.tlsEnabled).toBe(false); + expect(status.host).toBe("127.0.0.1"); + expect(status.port).toBeGreaterThan(0); + expect(daemon.port).toBe(status.port); + expect(ensureCertsMock).not.toHaveBeenCalled(); + }); + + it("applies restart TLS config before persistence and shutdown", async () => { + const { startDaemonProcess } = await import( + "../../../src/lib/effect/daemon-main.js" + ); + const socketPath = join(tmpDir, "relay.sock"); + + daemon = await startDaemonProcess({ + configDir: tmpDir, + socketPath, + pidPath: join(tmpDir, "daemon.pid"), + staticDir: tmpDir, + port: 0, + tlsEnabled: false, + smartDefault: false, + }); + + const response = await sendRestartConfig(socketPath, { + tls: true, + keepAwake: true, + }); + + expect(response).toEqual({ ok: true }); + expect(daemon.getStatus().tlsEnabled).toBe(true); + expect(daemon.getStatus().keepAwake).toBe(true); + const persisted = await waitForPersistedConfig( + tmpDir, + (config) => config.tls === true, + ); + expect(persisted.tls).toBe(true); + }); + + it("keeps keep-awake startup config in the runtime-backed ref and persisted config", async () => { + const { startDaemonProcess } = await import( + "../../../src/lib/effect/daemon-main.js" + ); + + daemon = await startDaemonProcess({ + configDir: tmpDir, + socketPath: join(tmpDir, "relay.sock"), + pidPath: join(tmpDir, "daemon.pid"), + staticDir: tmpDir, + port: 0, + tlsEnabled: false, + smartDefault: false, + keepAwake: true, + keepAwakeCommand: "printf", + keepAwakeArgs: ["awake"], + }); + + const status = daemon.getStatus(); + expect(status.keepAwake).toBe(true); + + await daemon.stop(); + daemon = null; + + const persisted = loadDaemonConfig(tmpDir); + expect(persisted?.keepAwake).toBe(true); + expect(persisted?.keepAwakeCommand).toBe("printf"); + expect(persisted?.keepAwakeArgs).toEqual(["awake"]); + }); + + it("keeps rehydrated dismissed paths and session counts in runtime-backed reads and persistence", async () => { + const { startDaemonProcess } = await import( + "../../../src/lib/effect/daemon-main.js" + ); + const projectPath = join(tmpDir, "persisted-project"); + const dismissedPath = join(tmpDir, "dismissed-project"); + + await saveDaemonConfig( + { + pid: 123, + port: 0, + pinHash: null, + tls: false, + debug: false, + keepAwake: false, + dangerouslySkipPermissions: false, + projects: [ + { + path: projectPath, + slug: "persisted-project", + title: "Persisted Project", + addedAt: 456, + sessionCount: 7, + }, + ], + instances: [], + dismissedPaths: [dismissedPath], + }, + tmpDir, + ); + + daemon = await startDaemonProcess({ + configDir: tmpDir, + socketPath: join(tmpDir, "relay.sock"), + pidPath: join(tmpDir, "daemon.pid"), + staticDir: tmpDir, + port: 0, + tlsEnabled: false, + smartDefault: false, + }); + + const status = daemon.getStatus(); + expect(status.sessionCount).toBe(7); + + await daemon.stop(); + daemon = null; + + const persisted = loadDaemonConfig(tmpDir); + expect(persisted?.dismissedPaths).toContain(dismissedPath); + expect(persisted?.projects).toContainEqual( + expect.objectContaining({ + slug: "persisted-project", + sessionCount: 7, + }), + ); + }); + + it("does not apply a local runtime config fallback when the runtime update fails", () => { + const initial = makeDaemonConfigFromOptions({ + port: 2633, + host: "127.0.0.1", + keepAwake: false, + }); + + expect(() => + resolveRuntimeConfigUpdateSync( + initial, + (config) => ({ ...config, keepAwake: true }), + () => { + throw new Error("ref update failed"); + }, + ), + ).toThrow("ref update failed"); + expect(initial.keepAwake).toBe(false); + }); + + it("still applies local runtime config updates before the runtime exists", () => { + const initial = makeDaemonConfigFromOptions({ + port: 2633, + host: "127.0.0.1", + keepAwake: false, + }); + + const updated = resolveRuntimeConfigUpdateSync( + initial, + (config) => ({ ...config, keepAwake: true }), + null, + ); + + expect(updated.keepAwake).toBe(true); + }); +}); diff --git a/test/unit/effect/http-server-live.test.ts b/test/unit/effect/http-server-live.test.ts new file mode 100644 index 00000000..2e94dd0f --- /dev/null +++ b/test/unit/effect/http-server-live.test.ts @@ -0,0 +1,360 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { request as httpRequest } from "node:http"; +import type { AddressInfo } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, it } from "@effect/vitest"; +import { Effect, Layer, Ref, type Scope } from "effect"; +import { expect } from "vitest"; +import type { DaemonLifecycleContext } from "../../../src/lib/daemon/daemon-lifecycle.js"; +import { + DaemonConfigRefLive, + DaemonConfigRefTag, + type DaemonRuntimeConfig, +} from "../../../src/lib/effect/daemon-config-ref.js"; +import { + makeHttpServerLive, + makeOnboardingServerLive, +} from "../../../src/lib/effect/daemon-layers.js"; +import { + EnsureCertsTag, + TlsCertLive, + TlsCertTag, +} from "../../../src/lib/effect/tls-cert-layer.js"; +import { makeTestTlsCerts } from "../../helpers/tls-cert-fixture.js"; + +const fixtureCerts = makeTestTlsCerts(); + +const baseConfig: DaemonRuntimeConfig = { + port: 0, + host: "127.0.0.1", + pinHash: null, + tlsEnabled: false, + keepAwake: false, + keepAwakeCommand: undefined, + keepAwakeArgs: undefined, + shuttingDown: false, + dismissedPaths: new Set(), + startTime: Date.now(), + hostExplicit: false, + persistedSessionCounts: new Map(), +}; + +const NullTlsLayer = Layer.succeed(TlsCertTag, { + certs: null, + caRootPath: null, + caCertDer: null, + caCertPem: null, +}); + +function makeContext(): DaemonLifecycleContext { + return { + httpServer: null, + upgradeServer: null, + onboardingServer: null, + ipcServer: null, + ipcClients: new Set(), + clientCount: 0, + socketPath: "/tmp/conduit-http-server-live.sock", + router: { + async handleRequest(_req, res) { + res.writeHead(200, { "Content-Type": "text/plain" }); + res.end("ok"); + }, + }, + }; +} + +function boundAddress(ctx: DaemonLifecycleContext): AddressInfo { + const addr = ctx.httpServer?.address(); + if (!addr || typeof addr === "string") { + throw new Error("HTTP server did not bind to an IP address"); + } + return addr; +} + +function onboardingPort(ctx: DaemonLifecycleContext): number { + const addr = ctx.onboardingServer?.address(); + if (!addr || typeof addr === "string") { + throw new Error("Onboarding server did not bind to an IP address"); + } + return addr.port; +} + +function httpGet( + port: number, + path: string, +): Promise<{ + status: number; + body: Buffer; + headers: Record; +}> { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + const req = httpRequest( + { + hostname: "127.0.0.1", + port, + path, + method: "GET", + }, + (res) => { + res.on("data", (chunk: Buffer) => { + chunks.push(chunk); + }); + res.on("end", () => + resolve({ + status: res.statusCode ?? 0, + body: Buffer.concat(chunks), + headers: res.headers, + }), + ); + }, + ); + req.on("error", reject); + req.end(); + }); +} + +function makeStaticDir(): string { + return mkdtempSync(join(tmpdir(), "conduit-http-server-live-")); +} + +function withStaticDir( + use: (staticDir: string) => Effect.Effect, +): Effect.Effect { + return Effect.acquireRelease(Effect.sync(makeStaticDir), (staticDir) => + Effect.sync(() => rmSync(staticDir, { recursive: true, force: true })), + ).pipe(Effect.flatMap(use)); +} + +function activeTlsLayer(caCertDer: Buffer | null = null) { + return Layer.succeed(TlsCertTag, { + certs: fixtureCerts, + caRootPath: null, + caCertDer, + caCertPem: null, + }); +} + +describe("makeHttpServerLive", () => { + it.scoped("binds to the host from DaemonConfigRefTag", () => { + const ctx = makeContext(); + const configLayer = DaemonConfigRefLive({ + ...baseConfig, + host: "127.0.0.1", + port: 0, + }); + const testLayer = makeHttpServerLive(ctx).pipe( + Layer.provideMerge(configLayer), + Layer.provide(NullTlsLayer), + ); + + return Effect.sync(() => { + const addr = boundAddress(ctx); + expect(addr.address).toBe("127.0.0.1"); + expect(addr.port).toBeGreaterThan(0); + }).pipe(Effect.provide(Layer.fresh(testLayer))); + }); + + it.scoped("writes the actual bound port back to the config Ref", () => { + const ctx = makeContext(); + const configLayer = DaemonConfigRefLive({ + ...baseConfig, + port: 0, + }); + const testLayer = makeHttpServerLive(ctx).pipe( + Layer.provideMerge(configLayer), + Layer.provide(NullTlsLayer), + ); + + return Effect.gen(function* () { + const ref = yield* DaemonConfigRefTag; + const config = yield* Ref.get(ref); + const addr = boundAddress(ctx); + + expect(config.port).toBe(addr.port); + expect(config.port).toBeGreaterThan(0); + }).pipe(Effect.provide(Layer.fresh(testLayer))); + }); + + it.scoped( + "uses TLS material from TlsCertTag and starts the upgrade server", + () => { + const ctx = makeContext(); + const configLayer = DaemonConfigRefLive({ + ...baseConfig, + port: 0, + host: "127.0.0.1", + tlsEnabled: true, + }); + const tlsLayer = Layer.succeed(TlsCertTag, { + certs: fixtureCerts, + caRootPath: null, + caCertDer: null, + caCertPem: null, + }); + const testLayer = makeHttpServerLive(ctx).pipe( + Layer.provideMerge(configLayer), + Layer.provide(tlsLayer), + ); + + return Effect.sync(() => { + expect(ctx.upgradeServer).not.toBeNull(); + expect(boundAddress(ctx).port).toBeGreaterThan(0); + }).pipe(Effect.provide(Layer.fresh(testLayer))); + }, + ); + + it.scoped( + "uses real TlsCertLive handoff and binds TLS to 0.0.0.0 when host was not explicit", + () => { + const ctx = makeContext(); + const configLayer = DaemonConfigRefLive({ + ...baseConfig, + host: "127.0.0.1", + port: 0, + tlsEnabled: true, + hostExplicit: false, + }); + const ensureCertsLayer = Layer.succeed(EnsureCertsTag, { + ensureCerts: () => Effect.succeed(fixtureCerts), + }); + const tlsLayer = TlsCertLive("/tmp/conduit-http-server-live").pipe( + Layer.provideMerge(configLayer), + Layer.provide(ensureCertsLayer), + ); + const testLayer = makeHttpServerLive(ctx).pipe( + Layer.provideMerge(tlsLayer), + ); + + return Effect.gen(function* () { + const ref = yield* DaemonConfigRefTag; + const config = yield* Ref.get(ref); + const addr = boundAddress(ctx); + + expect(config.host).toBe("0.0.0.0"); + expect(addr.address).toBe("0.0.0.0"); + expect(ctx.upgradeServer).not.toBeNull(); + }).pipe(Effect.provide(Layer.fresh(testLayer))); + }, + ); +}); + +describe("makeOnboardingServerLive", () => { + it.scoped("skips when TlsCertTag has no certs", () => { + return withStaticDir((staticDir) => { + const ctx = makeContext(); + const configLayer = DaemonConfigRefLive(baseConfig); + const testLayer = makeOnboardingServerLive(ctx, { + staticDir, + caRootPath: null, + caCertDer: null, + }).pipe(Layer.provideMerge(configLayer), Layer.provide(NullTlsLayer)); + + return Effect.sync(() => { + expect(ctx.onboardingServer).toBeNull(); + }).pipe(Effect.provide(Layer.fresh(testLayer))); + }); + }); + + it.scoped( + "binds to the host from DaemonConfigRefTag when TLS is active", + () => { + return withStaticDir((staticDir) => { + const ctx = makeContext(); + const configLayer = DaemonConfigRefLive({ + ...baseConfig, + host: "127.0.0.1", + port: 0, + }); + const testLayer = makeOnboardingServerLive(ctx, { + staticDir, + caRootPath: null, + caCertDer: null, + }).pipe( + Layer.provideMerge(configLayer), + Layer.provide(activeTlsLayer()), + ); + + return Effect.sync(() => { + const addr = ctx.onboardingServer?.address(); + if (!addr || typeof addr === "string") { + throw new Error("Onboarding server did not bind"); + } + expect(addr.address).toBe("127.0.0.1"); + expect(addr.port).toBeGreaterThan(0); + }).pipe(Effect.provide(Layer.fresh(testLayer))); + }); + }, + ); + + it.scoped("serves CA DER material from TlsCertTag", () => { + return withStaticDir((staticDir) => { + const ctx = makeContext(); + const caPayload = Buffer.from("test-ca-der"); + const configLayer = DaemonConfigRefLive({ + ...baseConfig, + host: "127.0.0.1", + port: 0, + }); + const testLayer = makeOnboardingServerLive(ctx, { + staticDir, + caRootPath: null, + caCertDer: null, + }).pipe( + Layer.provideMerge(configLayer), + Layer.provide(activeTlsLayer(caPayload)), + ); + + return Effect.gen(function* () { + const response = yield* Effect.promise(() => + httpGet(onboardingPort(ctx), "/ca/download"), + ); + expect(response.status).toBe(200); + expect(response.body).toEqual(caPayload); + expect(response.headers["content-type"]).toBe( + "application/x-x509-ca-cert", + ); + }).pipe(Effect.provide(Layer.fresh(testLayer))); + }); + }); + + it.scoped( + "uses the actual main-server port in composed setup-info when HTTP started with port 0", + () => { + return withStaticDir((staticDir) => { + const ctx = makeContext(); + const configLayer = DaemonConfigRefLive({ + ...baseConfig, + host: "127.0.0.1", + port: 0, + tlsEnabled: true, + }); + const upstream = Layer.merge(configLayer, activeTlsLayer()); + const httpLayer = makeHttpServerLive(ctx).pipe( + Layer.provideMerge(upstream), + ); + const testLayer = makeOnboardingServerLive(ctx, { + staticDir, + caRootPath: null, + caCertDer: null, + }).pipe(Layer.provideMerge(httpLayer)); + + return Effect.gen(function* () { + const mainPort = boundAddress(ctx).port; + const response = yield* Effect.promise(() => + httpGet(onboardingPort(ctx), "/api/setup-info"), + ); + const body = JSON.parse(response.body.toString("utf8")) as { + httpsUrl: string; + }; + + expect(response.status).toBe(200); + expect(body.httpsUrl).toContain(`:${mainPort}`); + expect(body.httpsUrl).not.toContain(":0"); + }).pipe(Effect.provide(Layer.fresh(testLayer))); + }); + }, + ); +}); diff --git a/test/unit/effect/layer-wiring.test.ts b/test/unit/effect/layer-wiring.test.ts index e5c3b039..e4abc1c2 100644 --- a/test/unit/effect/layer-wiring.test.ts +++ b/test/unit/effect/layer-wiring.test.ts @@ -14,7 +14,10 @@ import { Deferred, Duration, Effect, Layer, PubSub, Ref } from "effect"; import { expect } from "vitest"; import type { OnboardingServerDeps } from "../../../src/lib/daemon/daemon-lifecycle.js"; import { AuthManagerTag } from "../../../src/lib/effect/auth-middleware.js"; -import { DaemonConfigRefTag } from "../../../src/lib/effect/daemon-config-ref.js"; +import { + DaemonConfigRefTag, + makeDaemonConfigFromOptions, +} from "../../../src/lib/effect/daemon-config-ref.js"; import { type DaemonLiveOptions, makeDaemonLive, @@ -55,8 +58,6 @@ const makeMockOptions = (): DaemonLiveOptions => { ctx: { // Use an ephemeral port so wiring tests can run while the local // development daemon owns the default conduit port. - port: 0, - host: "127.0.0.1", httpServer, upgradeServer: null, onboardingServer: null, @@ -105,6 +106,12 @@ const makeMockOptions = (): DaemonLiveOptions => { instances: [], }), onboarding: {} as OnboardingServerDeps, + initialConfig: makeDaemonConfigFromOptions({ + port: 0, + host: "127.0.0.1", + tlsEnabled: false, + hostExplicit: false, + }), // Background services with minimal configs keepAwake: {}, versionCheck: { @@ -141,15 +148,47 @@ describe("makeDaemonLive wiring", () => { }).pipe(Effect.provide(makeDaemonLayer())), ); + it.scoped("provides DaemonConfigRefTag with the actual ephemeral port", () => + Effect.gen(function* () { + const ref = yield* DaemonConfigRefTag; + const config = yield* Ref.get(ref); + expect(config.port).toBeGreaterThan(0); + expect(config.host).toBe("127.0.0.1"); + }).pipe(Effect.provide(makeDaemonLayer())), + ); + it.scoped( - "provides DaemonConfigRefTag with ephemeral initial port (Tier 0)", - () => - Effect.gen(function* () { + "makeDaemonLive seeds DaemonConfigRef from the full initial config", + () => { + const options = { + ...makeMockOptions(), + initialConfig: makeDaemonConfigFromOptions({ + port: 53123, + host: "0.0.0.0", + tlsEnabled: false, + hostExplicit: false, + keepAwake: true, + keepAwakeCommand: "printf", + keepAwakeArgs: ["awake"], + dismissedPaths: ["/tmp/dismissed"], + persistedSessionCounts: new Map([["persisted", 3]]), + }), + } satisfies DaemonLiveOptions; + + return Effect.gen(function* () { const ref = yield* DaemonConfigRefTag; const config = yield* Ref.get(ref); - expect(config.port).toBe(0); - expect(config.host).toBe("127.0.0.1"); - }).pipe(Effect.provide(makeDaemonLayer())), + expect(config.port).toBe(53123); + expect(config.host).toBe("0.0.0.0"); + expect(config.tlsEnabled).toBe(false); + expect(config.hostExplicit).toBe(false); + expect(config.keepAwake).toBe(true); + expect(config.keepAwakeCommand).toBe("printf"); + expect(config.keepAwakeArgs).toEqual(["awake"]); + expect(config.dismissedPaths.has("/tmp/dismissed")).toBe(true); + expect(config.persistedSessionCounts.get("persisted")).toBe(3); + }).pipe(Effect.provide(Layer.fresh(makeDaemonLive(options)))); + }, ); it.scoped("provides ShutdownSignalTag as a Deferred (Tier 0)", () => diff --git a/test/unit/server/http-server-layer.test.ts b/test/unit/server/http-server-layer.test.ts index 9f6669ba..892517f0 100644 --- a/test/unit/server/http-server-layer.test.ts +++ b/test/unit/server/http-server-layer.test.ts @@ -98,9 +98,12 @@ const TestThemeLayer = Layer.succeed(ThemeProvider, { }), }); +let setupInfoPort = 9999; +let setupInfoIsTls = false; + const TestSetupInfoLayer = Layer.succeed(SetupInfoProvider, { - port: 9999, - isTls: false, + getPort: () => setupInfoPort, + getIsTls: () => setupInfoIsTls, }); let staticDir = ""; @@ -225,6 +228,8 @@ describe("Effect HTTP Router - Extended Routes", () => { describe("GET /api/setup-info", () => { it("returns setup info when SetupInfoProvider present", async () => { + setupInfoPort = 9999; + setupInfoIsTls = false; const handler = tracked( Layer.merge(TestProjectsLayer, TestSetupInfoLayer), ); @@ -245,7 +250,39 @@ describe("Effect HTTP Router - Extended Routes", () => { expect(body.httpUrl).toContain("http://"); }); + it("reflects live SetupInfoProvider values", async () => { + setupInfoPort = 8181; + setupInfoIsTls = true; + const handler = tracked( + Layer.merge(TestProjectsLayer, TestSetupInfoLayer), + ); + + const first = await handler( + new Request("http://localhost:9999/api/setup-info"), + ); + const firstBody = (await first.json()) as { + httpsUrl: string; + hasCert: boolean; + }; + expect(firstBody.httpsUrl).toContain(":8181"); + expect(firstBody.hasCert).toBe(true); + + setupInfoPort = 8282; + setupInfoIsTls = false; + const second = await handler( + new Request("http://localhost:9999/api/setup-info"), + ); + const secondBody = (await second.json()) as { + httpsUrl: string; + hasCert: boolean; + }; + expect(secondBody.httpsUrl).toContain(":8282"); + expect(secondBody.hasCert).toBe(false); + }); + it("respects ?mode=lan query parameter", async () => { + setupInfoPort = 9999; + setupInfoIsTls = false; const handler = tracked( Layer.merge(TestProjectsLayer, TestSetupInfoLayer), );