diff --git a/apps/server/src/cli/connect.test.ts b/apps/server/src/cli/connect.test.ts index f3cc88d1b58a66468abbcfe94bc48d01ebbc4e96..514a9b56a2a5f5d8f81a89933c36b62080d0bb2f 100644 --- a/apps/server/src/cli/connect.test.ts +++ b/apps/server/src/cli/connect.test.ts @@ -71,6 +71,7 @@ it.effect("does not install the relay client when the user declines the managed installCalls += 1; return managedExecutable; }), + pruneManagedVersions: Effect.void, }, () => Effect.succeed(false), () => Effect.void, @@ -104,6 +105,7 @@ it.effect("installs the relay client after the user accepts the managed download }), ), ), + pruneManagedVersions: Effect.void, }, () => Effect.succeed(true), (event) => @@ -128,6 +130,7 @@ it.effect("reuses an available relay client executable without prompting", () => resolve: Effect.succeed(managedExecutable), install: Effect.die("unexpected install"), installWithProgress: () => Effect.die("unexpected install"), + pruneManagedVersions: Effect.void, }, () => Effect.sync(() => { diff --git a/apps/server/src/cloud/ManagedEndpointRuntime.test.ts b/apps/server/src/cloud/ManagedEndpointRuntime.test.ts index 4b06ae2124ff5019d14f68fd5ed1877a7b4d8042..3a16c58fb60c71a47905349a0a81ce0e11c53d50 100644 --- a/apps/server/src/cloud/ManagedEndpointRuntime.test.ts +++ b/apps/server/src/cloud/ManagedEndpointRuntime.test.ts @@ -29,6 +29,7 @@ const layerRelayClientAvailable = Layer.succeed( }), install: Effect.die("unused"), installWithProgress: () => Effect.die("unused"), + pruneManagedVersions: Effect.void, }), ); @@ -829,6 +830,7 @@ describe("CloudManagedEndpointRuntime", () => { }), install: Effect.die("unused"), installWithProgress: () => Effect.die("unused"), + pruneManagedVersions: Effect.void, }), ), ); @@ -847,4 +849,557 @@ describe("CloudManagedEndpointRuntime", () => { expect(spawn).not.toHaveBeenCalled(); }), ); + + it.effect( + "runs an older relay client while the pinned one installs, then restarts on it and prunes", + () => + Effect.gen(function* () { + const olderClient = { + status: "available", + executablePath: "/managed/2025.9.0/cloudflared", + source: "managed", + version: "2025.9.0", + } as const; + const pinnedClient = { + status: "available", + executablePath: "/managed/pinned/cloudflared", + source: "managed", + version: RelayClient.CLOUDFLARED_VERSION, + } as const; + let installed = false; + const releaseInstall = yield* Deferred.make(); + const pruned = yield* Deferred.make(); + const spawned = yield* Queue.unbounded(); + const killed: Array = []; + const output = yield* Queue.unbounded(); + const spawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + const executable = ChildProcess.isStandardCommand(command) ? command.command : ""; + yield* Queue.offer(spawned, executable); + const handle = makeHandle({ + pid: executable === pinnedClient.executablePath ? 2 : 1, + onKill: () => killed.push(executable), + ...(executable === pinnedClient.executablePath + ? { output: Stream.fromQueue(output) } + : {}), + }); + yield* Effect.addFinalizer(() => handle.kill().pipe(Effect.ignore)); + return handle; + }), + ); + const runtime = yield* buildCloudManagedEndpointRuntime( + spawner, + Layer.succeed( + RelayClient.RelayClient, + RelayClient.RelayClient.of({ + resolve: Effect.sync(() => (installed ? pinnedClient : olderClient)), + install: Deferred.await(releaseInstall).pipe( + Effect.andThen( + Effect.sync(() => { + installed = true; + return pinnedClient; + }), + ), + ), + installWithProgress: () => Effect.die("unused"), + pruneManagedVersions: Deferred.succeed(pruned, undefined).pipe(Effect.asVoid), + }), + ), + ); + + const status = yield* runtime.applyConfig({ + providerKind: "cloudflare_tunnel", + connectorToken: "token", + }); + expect(status).toMatchObject({ status: "running", pid: 1 }); + expect(yield* Queue.take(spawned)).toBe(olderClient.executablePath); + + yield* Deferred.succeed(releaseInstall, undefined); + expect(yield* Queue.take(spawned)).toBe(pinnedClient.executablePath); + expect(killed).toEqual([olderClient.executablePath]); + + yield* Queue.offer( + output, + new TextEncoder().encode( + "2026-10-08T00:00:00Z INF Registered tunnel connection connIndex=0\n", + ), + ); + yield* Deferred.await(pruned); + }), + ); + + it.effect("installs a missing relay client in the background and then starts it", () => + Effect.gen(function* () { + const pinnedClient = { + status: "available", + executablePath: "/managed/pinned/cloudflared", + source: "managed", + version: RelayClient.CLOUDFLARED_VERSION, + } as const; + let installed = false; + const spawned = yield* Deferred.make(); + const spawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + yield* Deferred.succeed( + spawned, + ChildProcess.isStandardCommand(command) ? command.command : "", + ); + const handle = makeHandle({ pid: 3, onKill: () => {} }); + yield* Effect.addFinalizer(() => handle.kill().pipe(Effect.ignore)); + return handle; + }), + ); + const runtime = yield* buildCloudManagedEndpointRuntime( + spawner, + Layer.succeed( + RelayClient.RelayClient, + RelayClient.RelayClient.of({ + resolve: Effect.sync(() => + installed + ? pinnedClient + : { status: "missing" as const, version: RelayClient.CLOUDFLARED_VERSION }, + ), + install: Effect.sync(() => { + installed = true; + return pinnedClient; + }), + installWithProgress: () => Effect.die("unused"), + pruneManagedVersions: Effect.void, + }), + ), + ); + + const status = yield* runtime.applyConfig({ + providerKind: "cloudflare_tunnel", + connectorToken: "token", + }); + // The first attempt reports the gap; the install then starts the connector itself. + expect(status).toMatchObject({ status: "failed", failure: "not-installed" }); + expect(yield* Deferred.await(spawned)).toBe(pinnedClient.executablePath); + }), + ); + + it.effect("leaves a PATH relay client alone", () => + Effect.gen(function* () { + const install = vi.fn(() => Effect.die("unexpected install")); + const spawner = ChildProcessSpawner.make(() => + Effect.gen(function* () { + const handle = makeHandle({ pid: 8, onKill: () => {} }); + yield* Effect.addFinalizer(() => handle.kill().pipe(Effect.ignore)); + return handle; + }), + ); + const runtime = yield* buildCloudManagedEndpointRuntime( + spawner, + Layer.succeed( + RelayClient.RelayClient, + RelayClient.RelayClient.of({ + resolve: Effect.succeed({ + status: "available", + executablePath: "/opt/homebrew/bin/cloudflared", + source: "path", + version: "2026.10.0", + }), + install: Effect.suspend(install), + installWithProgress: () => Effect.die("unused"), + pruneManagedVersions: Effect.void, + }), + ), + ); + expect( + yield* runtime.applyConfig({ providerKind: "cloudflare_tunnel", connectorToken: "token" }), + ).toMatchObject({ status: "running", pid: 8 }); + yield* TestClock.adjust(Duration.minutes(30)); + expect(install).not.toHaveBeenCalled(); + }), + ); + + it.effect("leaves an override relay client alone", () => + Effect.gen(function* () { + const install = vi.fn(() => Effect.die("unexpected install")); + const spawner = ChildProcessSpawner.make(() => + Effect.gen(function* () { + const handle = makeHandle({ pid: 4, onKill: () => {} }); + yield* Effect.addFinalizer(() => handle.kill().pipe(Effect.ignore)); + return handle; + }), + ); + const runtime = yield* buildCloudManagedEndpointRuntime( + spawner, + Layer.succeed( + RelayClient.RelayClient, + RelayClient.RelayClient.of({ + resolve: Effect.succeed({ + status: "available", + executablePath: "/opt/cloudflared", + source: "override", + version: "2025.10.0", + }), + install: Effect.suspend(install), + installWithProgress: () => Effect.die("unused"), + pruneManagedVersions: Effect.void, + }), + ), + ); + expect( + yield* runtime.applyConfig({ providerKind: "cloudflare_tunnel", connectorToken: "token" }), + ).toMatchObject({ status: "running", pid: 4 }); + yield* Effect.yieldNow; + expect(install).not.toHaveBeenCalled(); + }), + ); + + it.effect("retries a failed background install while the older connector keeps running", () => + Effect.gen(function* () { + const olderClient = { + status: "available", + executablePath: "/managed/2025.9.0/cloudflared", + source: "managed", + version: "2025.9.0", + } as const; + const pinnedClient = { + status: "available", + executablePath: "/managed/pinned/cloudflared", + source: "managed", + version: RelayClient.CLOUDFLARED_VERSION, + } as const; + let installed = false; + let installAttempts = 0; + const spawned = yield* Queue.unbounded(); + const spawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + yield* Queue.offer( + spawned, + ChildProcess.isStandardCommand(command) ? command.command : "", + ); + const handle = makeHandle({ pid: 5, onKill: () => {} }); + yield* Effect.addFinalizer(() => handle.kill().pipe(Effect.ignore)); + return handle; + }), + ); + const runtime = yield* buildCloudManagedEndpointRuntime( + spawner, + Layer.succeed( + RelayClient.RelayClient, + RelayClient.RelayClient.of({ + resolve: Effect.sync(() => (installed ? pinnedClient : olderClient)), + install: Effect.suspend(() => { + installAttempts += 1; + if (installAttempts === 1) { + // A real download fails some time after it starts. + return Effect.sleep(Duration.seconds(30)).pipe( + Effect.andThen( + Effect.fail( + new RelayClient.RelayClientInstallError({ + reason: "download_failed", + message: "offline", + }), + ), + ), + ); + } + installed = true; + return Effect.succeed(pinnedClient); + }), + installWithProgress: () => Effect.die("unused"), + pruneManagedVersions: Effect.void, + }), + ), + ); + + yield* runtime.applyConfig({ providerKind: "cloudflare_tunnel", connectorToken: "token" }); + expect(yield* Queue.take(spawned)).toBe(olderClient.executablePath); + yield* TestClock.adjust(Duration.seconds(30)); + expect(installAttempts).toBe(1); + + yield* TestClock.adjust(Duration.minutes(10)); + expect(yield* Queue.take(spawned)).toBe(pinnedClient.executablePath); + expect(installAttempts).toBe(2); + }), + ); + + it.effect("requests recovery when the updated relay client fails to start", () => + Effect.gen(function* () { + const olderClient = { + status: "available", + executablePath: "/managed/2025.9.0/cloudflared", + source: "managed", + version: "2025.9.0", + } as const; + const pinnedClient = { + status: "available", + executablePath: "/managed/pinned/cloudflared", + source: "managed", + version: RelayClient.CLOUDFLARED_VERSION, + } as const; + let installed = false; + const releaseInstall = yield* Deferred.make(); + const spawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + const executable = ChildProcess.isStandardCommand(command) ? command.command : ""; + if (executable === pinnedClient.executablePath) { + return yield* PlatformError.systemError({ + _tag: "PermissionDenied", + module: "ChildProcess", + method: "spawn", + }); + } + const handle = makeHandle({ pid: 6, onKill: () => {} }); + yield* Effect.addFinalizer(() => handle.kill().pipe(Effect.ignore)); + return handle; + }), + ); + const runtime = yield* buildCloudManagedEndpointRuntime( + spawner, + Layer.succeed( + RelayClient.RelayClient, + RelayClient.RelayClient.of({ + resolve: Effect.sync(() => (installed ? pinnedClient : olderClient)), + install: Deferred.await(releaseInstall).pipe( + Effect.andThen( + Effect.sync(() => { + installed = true; + return pinnedClient; + }), + ), + ), + installWithProgress: () => Effect.die("unused"), + pruneManagedVersions: Effect.void, + }), + ), + ); + const config = { providerKind: "cloudflare_tunnel", connectorToken: "token" } as const; + expect(yield* runtime.applyConfig(config)).toMatchObject({ status: "running", pid: 6 }); + + yield* Deferred.succeed(releaseInstall, undefined); + expect(Option.getOrThrow(yield* Stream.runHead(runtime.recoveryRequests))).toEqual(config); + }), + ); + + it.effect("restarts a self-updated pinned-folder connector once the pin is reinstalled", () => + Effect.gen(function* () { + const executablePath = "/managed/pinned/cloudflared"; + let version = "2026.9.3"; + const releaseInstall = yield* Deferred.make(); + const spawned = yield* Queue.unbounded(); + const spawner = ChildProcessSpawner.make(() => + Effect.gen(function* () { + yield* Queue.offer(spawned, version); + const handle = makeHandle({ pid: 7, onKill: () => {} }); + yield* Effect.addFinalizer(() => handle.kill().pipe(Effect.ignore)); + return handle; + }), + ); + const current = () => + ({ status: "available", executablePath, source: "managed", version }) as const; + const runtime = yield* buildCloudManagedEndpointRuntime( + spawner, + Layer.succeed( + RelayClient.RelayClient, + RelayClient.RelayClient.of({ + resolve: Effect.sync(current), + install: Deferred.await(releaseInstall).pipe( + Effect.andThen( + Effect.sync(() => { + version = RelayClient.CLOUDFLARED_VERSION; + return current(); + }), + ), + ), + installWithProgress: () => Effect.die("unused"), + pruneManagedVersions: Effect.void, + }), + ), + ); + + yield* runtime.applyConfig({ providerKind: "cloudflare_tunnel", connectorToken: "token" }); + expect(yield* Queue.take(spawned)).toBe("2026.9.3"); + yield* Deferred.succeed(releaseInstall, undefined); + expect(yield* Queue.take(spawned)).toBe(RelayClient.CLOUDFLARED_VERSION); + }), + ); + + it.effect("retries a failed install when no relay client is installed", () => + Effect.gen(function* () { + const pinnedClient = { + status: "available", + executablePath: "/managed/pinned/cloudflared", + source: "managed", + version: RelayClient.CLOUDFLARED_VERSION, + } as const; + let installed = false; + let installAttempts = 0; + const spawned = yield* Deferred.make(); + const spawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + yield* Deferred.succeed( + spawned, + ChildProcess.isStandardCommand(command) ? command.command : "", + ); + const handle = makeHandle({ pid: 9, onKill: () => {} }); + yield* Effect.addFinalizer(() => handle.kill().pipe(Effect.ignore)); + return handle; + }), + ); + const runtime = yield* buildCloudManagedEndpointRuntime( + spawner, + Layer.succeed( + RelayClient.RelayClient, + RelayClient.RelayClient.of({ + resolve: Effect.sync(() => + installed + ? pinnedClient + : { status: "missing" as const, version: RelayClient.CLOUDFLARED_VERSION }, + ), + install: Effect.suspend(() => { + installAttempts += 1; + if (installAttempts === 1) { + return Effect.fail( + new RelayClient.RelayClientInstallError({ + reason: "download_failed", + message: "offline", + }), + ); + } + installed = true; + return Effect.succeed(pinnedClient); + }), + installWithProgress: () => Effect.die("unused"), + pruneManagedVersions: Effect.void, + }), + ), + ); + + expect( + yield* runtime.applyConfig({ providerKind: "cloudflare_tunnel", connectorToken: "token" }), + ).toMatchObject({ status: "failed", failure: "not-installed" }); + yield* Effect.yieldNow; + expect(installAttempts).toBe(1); + + yield* TestClock.adjust(Duration.minutes(10)); + expect(yield* Deferred.await(spawned)).toBe(pinnedClient.executablePath); + expect(installAttempts).toBe(2); + }), + ); + + it.effect("restarts on the pin when the first post-install probe failed", () => + Effect.gen(function* () { + const olderClient = { + status: "available", + executablePath: "/managed/2025.9.0/cloudflared", + source: "managed", + version: "2025.9.0", + } as const; + const pinnedClient = { + status: "available", + executablePath: "/managed/pinned/cloudflared", + source: "managed", + version: RelayClient.CLOUDFLARED_VERSION, + } as const; + let installed = false; + // The first resolve after the install misses the new binary, as when a + // scanner briefly holds it; later resolves see it. + let missedProbes = 0; + const spawned = yield* Queue.unbounded(); + const spawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + yield* Queue.offer( + spawned, + ChildProcess.isStandardCommand(command) ? command.command : "", + ); + const handle = makeHandle({ pid: 10, onKill: () => {} }); + yield* Effect.addFinalizer(() => handle.kill().pipe(Effect.ignore)); + return handle; + }), + ); + const runtime = yield* buildCloudManagedEndpointRuntime( + spawner, + Layer.succeed( + RelayClient.RelayClient, + RelayClient.RelayClient.of({ + resolve: Effect.sync(() => { + if (installed && missedProbes === 0) { + missedProbes += 1; + return olderClient; + } + return installed ? pinnedClient : olderClient; + }), + install: Effect.sync(() => { + installed = true; + return pinnedClient; + }), + installWithProgress: () => Effect.die("unused"), + pruneManagedVersions: Effect.void, + }), + ), + ); + + yield* runtime.applyConfig({ providerKind: "cloudflare_tunnel", connectorToken: "token" }); + expect(yield* Queue.take(spawned)).toBe(olderClient.executablePath); + yield* Effect.yieldNow; + // The missed probe kept the older connector running. + expect(Option.isNone(yield* Queue.poll(spawned))).toBe(true); + + yield* TestClock.adjust(Duration.minutes(10)); + expect(yield* Queue.take(spawned)).toBe(pinnedClient.executablePath); + }), + ); + + it.effect("starts the connector when the pin landed but its first probe failed", () => + Effect.gen(function* () { + const pinnedClient = { + status: "available", + executablePath: "/managed/pinned/cloudflared", + source: "managed", + version: RelayClient.CLOUDFLARED_VERSION, + } as const; + const missing = { status: "missing" as const, version: RelayClient.CLOUDFLARED_VERSION }; + let installed = false; + // The resolve right after the install misses the new binary. + let missedProbes = 0; + const spawned = yield* Deferred.make(); + const spawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + yield* Deferred.succeed( + spawned, + ChildProcess.isStandardCommand(command) ? command.command : "", + ); + const handle = makeHandle({ pid: 11, onKill: () => {} }); + yield* Effect.addFinalizer(() => handle.kill().pipe(Effect.ignore)); + return handle; + }), + ); + const runtime = yield* buildCloudManagedEndpointRuntime( + spawner, + Layer.succeed( + RelayClient.RelayClient, + RelayClient.RelayClient.of({ + resolve: Effect.sync(() => { + if (!installed) return missing; + if (missedProbes === 0) { + missedProbes += 1; + return missing; + } + return pinnedClient; + }), + install: Effect.sync(() => { + installed = true; + return pinnedClient; + }), + installWithProgress: () => Effect.die("unused"), + pruneManagedVersions: Effect.void, + }), + ), + ); + + expect( + yield* runtime.applyConfig({ providerKind: "cloudflare_tunnel", connectorToken: "token" }), + ).toMatchObject({ status: "failed", failure: "not-installed" }); + yield* Effect.yieldNow; + expect(Option.isNone(yield* Deferred.poll(spawned))).toBe(true); + + yield* TestClock.adjust(Duration.minutes(10)); + expect(yield* Deferred.await(spawned)).toBe(pinnedClient.executablePath); + }), + ); }); diff --git a/apps/server/src/cloud/ManagedEndpointRuntime.ts b/apps/server/src/cloud/ManagedEndpointRuntime.ts index 6c601fad80f38815129b378a8af802a476161925..98deb8228d312dc20c0809b71498c96ecf7b6440 100644 --- a/apps/server/src/cloud/ManagedEndpointRuntime.ts +++ b/apps/server/src/cloud/ManagedEndpointRuntime.ts @@ -55,6 +55,7 @@ export class CloudManagedEndpointRuntime extends Context.Service< interface ActiveConnector { readonly child: ChildProcessSpawner.ChildProcessHandle; + readonly executable: RelayClient.AvailableRelayClient; readonly scope: Scope.Closeable; readonly configKey: string; readonly config: RelayManagedEndpointRuntimeConfig; @@ -77,6 +78,24 @@ const TUNNEL_AUTHORIZATION_FAILURES_BEFORE_RECOVERY = 4; // Ask for recovery after this long, and again at this interval while it stays // unconnected; the relay hands back the same tunnel when it is still live. const CONNECTOR_REGISTRATION_TIMEOUT = Duration.minutes(3); +// A failed background install of the pinned relay client is retried no sooner +// than this, so a crash-looping connector or an offline host does not redownload +// on every reconcile. +const RELAY_CLIENT_INSTALL_RETRY_INTERVAL_MS = 10 * 60_000; + +/** + * A linked host converges on the pinned managed release when it has no relay + * client or runs another managed release. A PATH binary or an explicit override + * is the user's choice, and the CLI asks before downloading, so both stay put. + */ +function needsPinnedRelayClient(executable: RelayClient.RelayClientStatus): boolean { + return ( + executable.status === "missing" || + (executable.status === "available" && + executable.source === "managed" && + !RelayClient.isPinnedManagedRelayClient(executable)) + ); +} export function classifyRelayClientOutput(line: string): "connected" | "warning" | "debug" { if (/\bRegistered tunnel connection\b/iu.test(line)) { @@ -148,6 +167,11 @@ export const make = Effect.gen(function* () { const reconcileSemaphore = yield* Semaphore.make(1); const restartDelayRef = yield* Ref.make(0); const linkStateSemaphore = yield* Semaphore.make(1); + const runtimeScope = yield* Effect.scope; + const installInFlightRef = yield* Ref.make(false); + const lastInstallFailureAtRef = yield* Ref.make(null); + const prunedRef = yield* Ref.make(false); + const retryLoopRunningRef = yield* Ref.make(false); let reconcileConfig: CloudManagedEndpointRuntime["Service"]["applyConfig"]; const stopActive = Effect.gen(function* () { @@ -228,6 +252,106 @@ export const make = Effect.gen(function* () { Effect.catchCause((cause) => Effect.logWarning("Relay client supervisor failed", { cause })), ); + // Installs the pinned relay client in the background, then restarts the + // connector on it. The running connector, if any, keeps serving until the new + // binary is installed and validated, so an update costs one brief reconnect. + const installPinnedRelayClient = Effect.gen(function* () { + if (yield* Ref.getAndSet(installInFlightRef, true)) return; + yield* Effect.logInfo("Installing the pinned relay client", { + version: RelayClient.CLOUDFLARED_VERSION, + }); + yield* relayClient.install.pipe( + Effect.tap(() => Ref.set(lastInstallFailureAtRef, null)), + Effect.flatMap((installed) => + reconcileSemaphore.withPermits(1)( + Effect.gen(function* () { + const desiredConfig = yield* Ref.get(desiredConfigRef); + if (!desiredConfig || desiredConfig.providerKind !== "cloudflare_tunnel") return; + const active = yield* Ref.get(activeRef); + // A self-updated binary in the pinned folder shares the installed path, + // so the version decides whether the running connector is current. + if ( + active?.executable.executablePath === installed.executablePath && + active.executable.version === installed.version + ) { + return; + } + // A fresh binary can fail its probe while a scanner holds it; restarting + // then would land on the same older binary, so leave it to the retry. + const resolved = yield* relayClient.resolve; + if ( + resolved.status !== "available" || + resolved.executablePath !== installed.executablePath || + resolved.version !== installed.version + ) { + return; + } + yield* Effect.logInfo("Relay client installed; restarting the connector on it", { + version: installed.version, + previousVersion: active?.executable.version, + }); + yield* stopActive; + const status = yield* reconcileConfig(desiredConfig); + // The old connector is gone and no supervisor watches a failed spawn, + // so ask for recovery like an exited connector would. + if (status.status === "failed") { + yield* Effect.logWarning("Relay client did not start after the update", status); + yield* Queue.offer(recoveryRequests, desiredConfig); + } + }), + ), + ), + Effect.catchTags({ + RelayClientInstallError: (error) => + Clock.currentTimeMillis.pipe( + Effect.flatMap((failedAt) => Ref.set(lastInstallFailureAtRef, failedAt)), + Effect.andThen( + Effect.logWarning("Could not install the pinned relay client", { + reason: error.reason, + message: error.message, + cause: error.cause, + }), + ), + ), + }), + Effect.ensuring(Ref.set(installInFlightRef, false)), + ); + }); + + // Reconciles call this; a recent failure skips the attempt so a crash-looping + // connector or an offline host does not redownload on every restart. + const ensurePinnedRelayClient = Effect.gen(function* () { + const now = yield* Clock.currentTimeMillis; + const lastFailureAt = yield* Ref.get(lastInstallFailureAtRef); + if (lastFailureAt !== null && now - lastFailureAt < RELAY_CLIENT_INSTALL_RETRY_INTERVAL_MS) { + return; + } + yield* installPinnedRelayClient; + }).pipe(Effect.forkIn(runtimeScope), Effect.asVoid); + + // Until the host runs the pinned release, retry the install every interval. + // applyConfig returns early while an older connector is healthy, and some + // activations never retry a missing client, so one runtime loop covers both. + // It paces itself, so it skips the failure gate above. + const retryPinnedRelayClientInstall = Effect.gen(function* () { + while (true) { + yield* Effect.sleep(Duration.millis(RELAY_CLIENT_INSTALL_RETRY_INTERVAL_MS)); + const desiredConfig = yield* Ref.get(desiredConfigRef); + if (!desiredConfig || desiredConfig.providerKind !== "cloudflare_tunnel") return; + // Stop only once a connector runs on the pin. The pin can be on disk while + // an older connector still runs, or while none runs because a post-install + // probe failed; another install call then finds it and starts the connector. + const active = yield* Ref.get(activeRef); + if (active && !needsPinnedRelayClient(active.executable)) return; + yield* installPinnedRelayClient; + } + }).pipe(Effect.ensuring(Ref.set(retryLoopRunningRef, false))); + + const startPinnedRelayClientRetries = Effect.gen(function* () { + if (yield* Ref.getAndSet(retryLoopRunningRef, true)) return; + yield* Effect.forkIn(retryPinnedRelayClientInstall, runtimeScope); + }); + // Requests recovery while the connector has not registered a connection, // once per timeout, until it connects or is replaced. const watchConnectorRegistration = (connector: ActiveConnector) => @@ -270,6 +394,15 @@ export const make = Effect.gen(function* () { Effect.logInfo("Relay client tunnel connection registered", attributes), ), Effect.andThen(Queue.offer(tunnelConnections, undefined)), + Effect.andThen( + RelayClient.isPinnedManagedRelayClient(connector.executable) + ? Ref.getAndSet(prunedRef, true).pipe( + Effect.flatMap((pruned) => + pruned ? Effect.void : relayClient.pruneManagedVersions, + ), + ) + : Effect.void, + ), Effect.asVoid, ); case "warning": @@ -328,6 +461,10 @@ export const make = Effect.gen(function* () { yield* stopActive; const executable = yield* relayClient.resolve; + if (needsPinnedRelayClient(executable)) { + yield* ensurePinnedRelayClient; + yield* startPinnedRelayClientRetries; + } if (executable.status !== "available") { return { status: "failed", @@ -365,6 +502,8 @@ export const make = Effect.gen(function* () { Effect.tap((child) => Effect.logInfo("Relay client process started; waiting for tunnel connection", { pid: Number(child.pid), + source: executable.source, + version: executable.version, tunnelId: config.tunnelId, tunnelName: config.tunnelName, }), @@ -395,6 +534,7 @@ export const make = Effect.gen(function* () { if (!("status" in child)) { const connector = { child, + executable, scope: connectorScope, configKey: nextConfigKey, config, diff --git a/docs/internals/t3-connect.md b/docs/internals/t3-connect.md index 0ea2c7dae2368b0b593df4cd4bd3de73ec1cc041..4f3e0620416439ba191f7d60a15fdac5f22272e5 100644 --- a/docs/internals/t3-connect.md +++ b/docs/internals/t3-connect.md @@ -130,6 +130,30 @@ and clients keep their bindings. Every mutation on an allocation bumps its `generation`, and deletion locks the row at the generation it claimed, so a host that reconnects mid-sweep wins. +## The relay client follows the server's pin + +The host runs `cloudflared` pinned by `CLOUDFLARED_VERSION` in +[`relayClient.ts`](../../packages/shared/src/relayClient.ts), and bumping it there +(version, URLs, and checksums) is the whole release step. A linked host that +starts a server with a new pin keeps its connector up on the newest older managed +release, installs the pinned one in the background, then restarts only the +connector child on it. If that restart fails to spawn, the host requests recovery +as it would for an exited connector. A host with no relay client installs one +first. A `cloudflared` on `PATH` or an explicit `T3CODE_CLOUDFLARED_PATH` is the +user's choice and is never replaced, since linking asks before downloading. + +Once the pinned release registers a connection, managed releases older than the +pin are deleted except the newest of them. That one, and any newer release, can +belong to another server sharing the same T3 home, such as a rollback after a +failed update, which could not run again without it. + +The version comes from running `cloudflared version`, not from the folder name. +Connectors from before `--no-autoupdate` replaced themselves in place, so a +managed folder can hold another release. Such a binary still runs as a fallback +while the pin installs. Every binary needs `CLOUDFLARED_MIN_VERSION`, the oldest +release that accepts every flag the connector is started with. Raise it whenever +a new flag is added. + ## OAuth traps Interactive clients and the headless CLI use the same Clerk application but diff --git a/packages/shared/src/relayClient.test.ts b/packages/shared/src/relayClient.test.ts index f4a6b29cb5858d5f967ce8786c99d91d4925af6d..85482f876c53398b94dadb20ff25c25abbe5df4a 100644 --- a/packages/shared/src/relayClient.test.ts +++ b/packages/shared/src/relayClient.test.ts @@ -25,7 +25,8 @@ const layerHostRuntime = (env: Record = {}) => ConfigProvider.layer(ConfigProvider.fromEnv({ env })), ); -function makeHandle(exitCode = 0) { +function makeHandle(exitCode = 0, output = "") { + const stdout = output ? Stream.make(new TextEncoder().encode(output)) : Stream.empty; return ChildProcessSpawner.makeHandle({ pid: ChildProcessSpawner.ProcessId(100), exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(exitCode)), @@ -33,9 +34,9 @@ function makeHandle(exitCode = 0) { kill: () => Effect.void, unref: Effect.succeed(Effect.void), stdin: Sink.drain, - stdout: Stream.empty, + stdout, stderr: Stream.empty, - all: Stream.empty, + all: stdout, getInputFd: () => Sink.drain, getOutputFd: () => Stream.empty, }); @@ -51,20 +52,45 @@ const layerHttpClient = (bytes: Uint8Array) => ), ); -const layerSpawner = (commands: Array) => +// Answers `cloudflared version` with the version recorded for that path, which +// defaults to the pinned release; tests change it to simulate other binaries. +const layerSpawner = (commands: Array, versions: Record = {}) => Layer.succeed( ChildProcessSpawner.ChildProcessSpawner, ChildProcessSpawner.make((command) => Effect.sync(() => { - commands.push(ChildProcess.isStandardCommand(command) ? command.command : "piped-command"); - // The pinned Windows executable rejects --version but accepts the version subcommand. - return makeHandle( - ChildProcess.isStandardCommand(command) && command.args.includes("--version") ? 1 : 0, - ); + if (!ChildProcess.isStandardCommand(command)) { + commands.push("piped-command"); + return makeHandle(); + } + commands.push(command.command); + if (command.args[0] === "version") { + const version = versions[command.command] ?? RelayClient.CLOUDFLARED_VERSION; + return makeHandle(0, `cloudflared version ${version} (built 2026-01-01-00:00 UTC)\n`); + } + return makeHandle(); }), ), ); +const writeExecutable = (filePath: string, contents = "cloudflared") => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + yield* fileSystem.makeDirectory(filePath.slice(0, filePath.lastIndexOf("/")), { + recursive: true, + }); + yield* fileSystem.writeFileString(filePath, contents); + yield* fileSystem.chmod(filePath, 0o755); + }); + +type RelayClientTestServices = + | Layer.Success | typeof NodeServices.layer> + | HttpClient.HttpClient + | ChildProcessSpawner.ChildProcessSpawner; + +const managedPathFor = (baseDir: string, version: string) => + `${baseDir}/tools/cloudflared/${version}/linux-x64/cloudflared`; + describe("RelayClient", () => { it.effect.skipIf(windowsHost)( "resolves explicit overrides before managed and PATH executables", @@ -220,7 +246,9 @@ describe("RelayClient", () => { concurrency: "unbounded", }); expect(second).toEqual(first); - expect(commands).toHaveLength(1); + // The first install validates the download once; the second, after waiting + // its turn, probes the activated binary and reuses it instead of downloading. + expect(commands).toHaveLength(2); }).pipe( Effect.scoped, Effect.provide( @@ -278,4 +306,179 @@ describe("RelayClient", () => { ); }, ); + + describe("version selection", () => { + const run = ( + body: (baseDir: string) => Effect.Effect, + options: { readonly versions?: Record } = {}, + ) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-cloudflared-test-", + }); + return yield* body(baseDir); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll( + NodeServices.layer, + layerHttpClient(new Uint8Array()), + layerSpawner([], options.versions), + layerHostRuntime({ PATH: "" }), + ), + ), + ); + + it.effect.skipIf(windowsHost)("skips a PATH binary older than the minimum version", () => { + const versions: Record = {}; + return run( + (baseDir) => + Effect.gen(function* () { + const oldPath = `${baseDir}/old/cloudflared`; + const newPath = `${baseDir}/new/cloudflared`; + yield* writeExecutable(oldPath); + yield* writeExecutable(newPath); + versions[oldPath] = "2023.8.2"; + versions[newPath] = RelayClient.CLOUDFLARED_MIN_VERSION; + const manager = yield* RelayClient.makeCloudflaredRelayClient({ baseDir }); + const env = { PATH: `${baseDir}/old:${baseDir}/new` }; + expect( + yield* manager.resolve.pipe( + Effect.provideService( + ConfigProvider.ConfigProvider, + ConfigProvider.fromEnv({ env }), + ), + ), + ).toEqual({ + status: "available", + executablePath: newPath, + source: "path", + version: RelayClient.CLOUDFLARED_MIN_VERSION, + }); + }), + { versions }, + ); + }); + + it.effect.skipIf(windowsHost)("reports an outdated override as missing", () => { + const versions: Record = {}; + return run( + (baseDir) => + Effect.gen(function* () { + const overridePath = `${baseDir}/override/cloudflared`; + yield* writeExecutable(overridePath); + versions[overridePath] = "2025.6.0"; + const manager = yield* RelayClient.makeCloudflaredRelayClient({ baseDir }); + const env = { PATH: "", T3CODE_CLOUDFLARED_PATH: overridePath }; + const withEnv = Effect.provideService( + ConfigProvider.ConfigProvider, + ConfigProvider.fromEnv({ env }), + ); + expect(yield* manager.resolve.pipe(withEnv)).toEqual({ + status: "missing", + version: RelayClient.CLOUDFLARED_VERSION, + }); + const error = yield* manager.install.pipe(withEnv, Effect.flip); + expect(error.reason).toBe("override_missing"); + }), + { versions }, + ); + }); + + it.effect.skipIf(windowsHost)( + "prefers the pinned release, then the newest older managed release, then PATH", + () => { + const versions: Record = {}; + return run( + (baseDir) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const pathBinary = `${baseDir}/bin/cloudflared`; + const older = managedPathFor(baseDir, "2025.9.0"); + const newer = managedPathFor(baseDir, "2026.1.0"); + const pinned = managedPathFor(baseDir, RelayClient.CLOUDFLARED_VERSION); + // A newer server sharing this base dir owns this folder. + const newerServer = managedPathFor(baseDir, "2099.1.0"); + versions[newerServer] = "2099.1.0"; + yield* writeExecutable(newerServer); + versions[pathBinary] = "2026.9.3"; + versions[older] = "2025.9.0"; + versions[newer] = "2026.1.0"; + yield* writeExecutable(pathBinary); + yield* writeExecutable(older); + yield* writeExecutable(newer); + const manager = yield* RelayClient.makeCloudflaredRelayClient({ baseDir }); + const resolveWith = manager.resolve.pipe( + Effect.provideService( + ConfigProvider.ConfigProvider, + ConfigProvider.fromEnv({ env: { PATH: `${baseDir}/bin` } }), + ), + ); + + expect(yield* resolveWith).toMatchObject({ source: "managed", version: "2026.1.0" }); + + yield* writeExecutable(pinned); + expect(yield* resolveWith).toMatchObject({ + executablePath: pinned, + source: "managed", + version: RelayClient.CLOUDFLARED_VERSION, + }); + + yield* manager.pruneManagedVersions; + expect( + (yield* fileSystem.readDirectory(`${baseDir}/tools/cloudflared`)).sort(), + ).toEqual(["2026.1.0", RelayClient.CLOUDFLARED_VERSION, "2099.1.0"]); + + // The kept older release still outranks PATH; PATH is the last resort. + yield* fileSystem.remove(pinned); + expect(yield* resolveWith).toMatchObject({ source: "managed", version: "2026.1.0" }); + yield* fileSystem.remove(`${baseDir}/tools/cloudflared/2026.1.0`, { + recursive: true, + }); + expect(yield* resolveWith).toMatchObject({ source: "path", version: "2026.9.3" }); + }), + { versions }, + ); + }, + ); + + it.effect.skipIf(windowsHost)( + "keeps a self-updated managed binary as a fallback but still installs the pin", + () => { + const versions: Record = {}; + return run( + (baseDir) => + Effect.gen(function* () { + const pinned = managedPathFor(baseDir, RelayClient.CLOUDFLARED_VERSION); + versions[pinned] = "2026.9.3"; + yield* writeExecutable(pinned); + const manager = yield* RelayClient.makeCloudflaredRelayClient({ baseDir }); + const resolved = yield* manager.resolve; + expect(resolved).toEqual({ + status: "available", + executablePath: pinned, + source: "managed", + version: "2026.9.3", + }); + expect( + resolved.status === "available" && RelayClient.isPinnedManagedRelayClient(resolved), + ).toBe(false); + }), + { versions }, + ); + }, + ); + }); + + it("orders and parses cloudflared versions", () => { + expect( + RelayClient.parseCloudflaredVersionOutput( + "cloudflared version 2026.5.2 (built 2026-05-20-10:00 UTC)", + ), + ).toBe("2026.5.2"); + expect(RelayClient.parseCloudflaredVersionOutput("Incorrect Usage")).toBeNull(); + expect(RelayClient.compareCloudflaredVersions("2025.10.0", "2025.6.1")).toBeGreaterThan(0); + expect(RelayClient.compareCloudflaredVersions("2023.8.2", "2025.6.1")).toBeLessThan(0); + }); }); diff --git a/packages/shared/src/relayClient.ts b/packages/shared/src/relayClient.ts index 57576cad32f3ec5f2942d684bec98beb53777c33..67dee30bdcd576a218a076973d7c3016e3c15e0d 100644 --- a/packages/shared/src/relayClient.ts +++ b/packages/shared/src/relayClient.ts @@ -1,3 +1,4 @@ +import * as Cache from "effect/Cache"; import * as Clock from "effect/Clock"; import type { RelayClientInstallProgressEvent, @@ -7,7 +8,9 @@ import * as Config from "effect/Config"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; import * as Data from "effect/Data"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as Hex from "effect/encoding/Hex"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -20,6 +23,10 @@ import { ChildProcess, ChildProcessSpawner } from "effect/process"; import { HostProcessArchitecture, HostProcessPlatform } from "./hostProcess.ts"; export const CLOUDFLARED_VERSION = "2026.5.2"; +// The oldest release that accepts every flag the connector is started with +// (`--output` arrived in 2025.6.1). Override, PATH, and older managed binaries +// below it are skipped, since they exit immediately on the unknown flag. +export const CLOUDFLARED_MIN_VERSION = "2025.6.1"; const CLOUDFLARED_PATH_ENV_NAME = "T3CODE_CLOUDFLARED_PATH"; export type RelayClientExecutableSource = "override" | "managed" | "path"; @@ -96,11 +103,18 @@ const CLOUDFLARED_RELEASE_ASSETS: Readonly< sha256: "20b9638f685333d623798e733effbad2487093f15ba592f6c7752360ff3b7ab7", archive: "binary", }, + // Cloudflare publishes no Windows ARM64 build; Windows 11 on ARM runs x64 under emulation. + "win32-arm64": { + url: "https://github.com/cloudflare/cloudflared/releases/download/2026.5.2/cloudflared-windows-amd64.exe", + sha256: "20b9638f685333d623798e733effbad2487093f15ba592f6c7752360ff3b7ab7", + archive: "binary", + }, }; const INSTALL_LOCK_RETRY_COUNT = 100; const INSTALL_LOCK_RETRY_DELAY = "100 millis"; const INSTALL_LOCK_STALE_MS = 5 * 60 * 1_000; +const VERSION_PROBE_TIMEOUT = "10 seconds"; const trimmedString = (name: string) => Config.String(name).pipe( @@ -124,11 +138,25 @@ export interface CloudflaredRelayClientOptions { } export interface RelayClientShape { + /** + * Finds the relay client to run, without downloading anything: the override, + * else the pinned managed folder, else the newest older managed folder, else + * `cloudflared` on PATH. Every candidate must report a compatible version, and + * the status carries the version the binary reports. + */ readonly resolve: Effect.Effect; + /** Installs the pinned managed release unless it, or a valid override, is already present. */ readonly install: Effect.Effect; readonly installWithProgress: ( report: (event: RelayClientInstallProgressEvent) => Effect.Effect, ) => Effect.Effect; + /** Removes managed releases older than the pin, except the newest one. Call once the pin has connected. */ + readonly pruneManagedVersions: Effect.Effect; +} + +/** True when this is the pinned managed release, which needs no update. */ +export function isPinnedManagedRelayClient(client: AvailableRelayClient): boolean { + return client.source === "managed" && client.version === CLOUDFLARED_VERSION; } export class RelayClient extends Context.Service()( @@ -146,6 +174,33 @@ function resolveReleaseAsset( return CLOUDFLARED_RELEASE_ASSETS[`${platform}-${arch}`] ?? null; } +const VERSION_PATTERN = /\b(\d{4})\.(\d{1,2})\.(\d+)\b/u; + +function parseVersion(value: string): readonly [number, number, number] | null { + const match = VERSION_PATTERN.exec(value); + return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null; +} + +/** Orders `YYYY.M.P` cloudflared versions; unparseable versions sort first. */ +export function compareCloudflaredVersions(left: string, right: string): number { + const a = parseVersion(left) ?? [0, 0, 0]; + const b = parseVersion(right) ?? [0, 0, 0]; + return a[0] - b[0] || a[1] - b[1] || a[2] - b[2]; +} + +/** Reads the version from `cloudflared version` output, e.g. `cloudflared version 2026.5.2 (built ...)`. */ +export function parseCloudflaredVersionOutput(output: string): string | null { + const version = parseVersion(output); + return version ? version.join(".") : null; +} + +// A changed file is a new key, so a replaced binary is probed again. +class VersionProbeKey extends Data.Class<{ + readonly executablePath: string; + readonly size: number; + readonly mtimeMillis: number; +}> {} + function isAlreadyExists(error: PlatformError.PlatformError): boolean { return error.reason._tag === "AlreadyExists"; } @@ -190,14 +245,10 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function const arch = yield* HostProcessArchitecture; const releaseAsset = options.releaseAsset ?? resolveReleaseAsset(platform, arch); const loadCloudflaredConfig = Effect.suspend(() => CloudflaredConfig).pipe(Effect.orDie); - const managedPath = path.join( - options.baseDir, - "tools", - "cloudflared", - CLOUDFLARED_VERSION, - `${platform}-${arch}`, - executableFileName(platform), - ); + const managedRoot = path.join(options.baseDir, "tools", "cloudflared"); + const managedPathFor = (version: string) => + path.join(managedRoot, version, `${platform}-${arch}`, executableFileName(platform)); + const managedPath = managedPathFor(CLOUDFLARED_VERSION); const isExecutableFile = Effect.fn("cloudflared.isExecutableFile")(function* ( executablePath: string, @@ -207,6 +258,78 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function return platform === "win32" || (info.value.mode & 0o111) !== 0; }); + // `cloudflared version` costs a process spawn, so answers are cached per file + // revision. Before `--no-autoupdate`, a managed binary could replace itself in + // place, so the version is read from the binary, never from its folder name. + // A failed or timed-out probe is not cached, so a slow spawn under an AV scan + // does not hide a good binary until it changes on disk. + const versionCache = yield* Cache.makeWith( + (key: VersionProbeKey) => + spawner + .string( + ChildProcess.make(key.executablePath, ["version"], { stdin: "ignore", stderr: "ignore" }), + ) + .pipe( + Effect.map(parseCloudflaredVersionOutput), + Effect.timeoutOption(VERSION_PROBE_TIMEOUT), + Effect.map(Option.getOrNull), + Effect.orElseSucceed(() => null), + ), + { + capacity: 16, + timeToLive: (exit) => + Exit.isSuccess(exit) && exit.value !== null ? Duration.infinity : Duration.zero, + }, + ); + const probeVersion = Effect.fn("cloudflared.probeVersion")(function* (executablePath: string) { + const info = yield* fileSystem.stat(executablePath).pipe(Effect.option); + if (Option.isNone(info) || info.value.type !== "File") return null; + if (platform !== "win32" && (info.value.mode & 0o111) === 0) return null; + return yield* Cache.get( + versionCache, + new VersionProbeKey({ + executablePath, + size: Number(info.value.size), + mtimeMillis: Option.getOrUndefined(info.value.mtime)?.getTime() ?? 0, + }), + ); + }); + + const compatibleCandidate = Effect.fn("cloudflared.compatibleCandidate")(function* ( + executablePath: string, + source: RelayClientExecutableSource, + expectedVersion?: string, + ) { + const version = yield* probeVersion(executablePath); + if (version === null || compareCloudflaredVersions(version, CLOUDFLARED_MIN_VERSION) < 0) { + return null; + } + if (expectedVersion !== undefined && version !== expectedVersion) { + yield* Effect.logWarning("Ignoring a managed relay client that reports another version", { + executablePath, + expectedVersion, + version, + }); + return null; + } + return { status: "available", executablePath, source, version } satisfies AvailableRelayClient; + }); + + // Managed releases older than the pin, newest first. Newer folders belong to + // a newer server sharing this base dir and are never used or pruned here. + const olderManagedVersions = Effect.gen(function* () { + const entries = yield* fileSystem + .readDirectory(managedRoot) + .pipe(Effect.orElseSucceed(() => [])); + return entries + .filter( + (entry) => + parseVersion(entry) !== null && + compareCloudflaredVersions(entry, CLOUDFLARED_VERSION) < 0, + ) + .sort((left, right) => compareCloudflaredVersions(right, left)); + }); + const resolvePathExecutable = Effect.gen(function* () { const config = yield* loadCloudflaredConfig; const pathValue = Option.getOrUndefined(config.path); @@ -216,49 +339,63 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function const trimmed = directory.trim().replace(/^"|"$/gu, ""); if (trimmed.length === 0) continue; const candidate = path.join(trimmed, executableFileName(platform)); - if (yield* isExecutableFile(candidate)) return candidate; + if (!(yield* isExecutableFile(candidate))) continue; + const available = yield* compatibleCandidate(candidate, "path"); + if (available) return available; + yield* Effect.logWarning("Skipping an incompatible relay client on PATH", { + executablePath: candidate, + minimumVersion: CLOUDFLARED_MIN_VERSION, + }); } return null; }); + const missingStatus: RelayClientStatus = { status: "missing", version: CLOUDFLARED_VERSION }; + const unsupportedStatus: RelayClientStatus = { + status: "unsupported", + platform, + arch, + version: CLOUDFLARED_VERSION, + }; const resolve: RelayClientShape["resolve"] = Effect.gen(function* () { const config = yield* loadCloudflaredConfig; if (Option.isSome(config.executableOverride)) { - return (yield* isExecutableFile(config.executableOverride.value)) - ? { - status: "available", - executablePath: config.executableOverride.value, - source: "override", - version: CLOUDFLARED_VERSION, - } - : { status: "missing", version: CLOUDFLARED_VERSION }; + const override = yield* compatibleCandidate(config.executableOverride.value, "override"); + if (override) return override; + yield* Effect.logWarning( + `${CLOUDFLARED_PATH_ENV_NAME} must point to cloudflared ${CLOUDFLARED_MIN_VERSION} or newer`, + { executablePath: config.executableOverride.value }, + ); + return missingStatus; } - if (yield* isExecutableFile(managedPath)) { - return { - status: "available", - executablePath: managedPath, - source: "managed", - version: CLOUDFLARED_VERSION, - }; + // A managed binary reports the version it actually runs. One that replaced + // itself in place still serves as a fallback; the runtime sees it is not the + // pinned release and installs that. + const pinned = yield* compatibleCandidate(managedPath, "managed"); + if (pinned) return pinned; + for (const version of yield* olderManagedVersions) { + const older = yield* compatibleCandidate(managedPathFor(version), "managed"); + if (older) return older; } const pathExecutable = yield* resolvePathExecutable; - if (pathExecutable) { - return { - status: "available", - executablePath: pathExecutable, - source: "path", - version: CLOUDFLARED_VERSION, - }; + if (pathExecutable) return pathExecutable; + return releaseAsset ? missingStatus : unsupportedStatus; + }).pipe(Effect.withSpan("cloudflared.resolve")); + + const pruneManagedVersions: RelayClientShape["pruneManagedVersions"] = Effect.gen(function* () { + // The newest older release stays: an older server sharing this base dir, or a + // rollback after a failed update, may still need it and cannot redownload it. + for (const version of (yield* olderManagedVersions).slice(1)) { + // A connector from another server sharing this base dir may still run an + // older release; Windows refuses to delete it, so the next prune retries. + yield* fileSystem.remove(path.join(managedRoot, version), { recursive: true }).pipe( + Effect.tap(() => Effect.logInfo("Removed an older managed relay client", { version })), + Effect.catch((cause) => + Effect.logDebug("Could not remove an older managed relay client", { version, cause }), + ), + ); } - return releaseAsset - ? { status: "missing", version: CLOUDFLARED_VERSION } - : { - status: "unsupported", - platform, - arch, - version: CLOUDFLARED_VERSION, - }; - }); + }).pipe(Effect.withSpan("cloudflared.pruneManagedVersions")); const runCommand = Effect.fn("cloudflared.runCommand")(function* ( command: string, @@ -354,15 +491,17 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function report: (stage: RelayClientInstallProgressStage) => Effect.Effect, ) { yield* report("checking"); - const existing = yield* resolve; - if (existing.status === "available") return existing; const config = yield* loadCloudflaredConfig; if (Option.isSome(config.executableOverride)) { + const override = yield* compatibleCandidate(config.executableOverride.value, "override"); + if (override) return override; return yield* new RelayClientInstallError({ reason: "override_missing", - message: `${CLOUDFLARED_PATH_ENV_NAME} does not point to an executable file.`, + message: `${CLOUDFLARED_PATH_ENV_NAME} must point to cloudflared ${CLOUDFLARED_MIN_VERSION} or newer.`, }); } + const existing = yield* compatibleCandidate(managedPath, "managed", CLOUDFLARED_VERSION); + if (existing) return existing; if (!releaseAsset) { return yield* new RelayClientInstallError({ reason: "unsupported_platform", @@ -391,8 +530,8 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function }), ); return yield* Effect.gen(function* () { - const afterLock = yield* resolve; - if (afterLock.status === "available") return afterLock; + const afterLock = yield* compatibleCandidate(managedPath, "managed", CLOUDFLARED_VERSION); + if (afterLock) return afterLock; const tempDirectory = yield* fileSystem.makeTempDirectoryScoped({ directory: managedDirectory, @@ -420,9 +559,18 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function .pipe(wrapInstallFailure("write_failed", "Could not make the relay client executable.")); } yield* report("validating"); - yield* runCommand(executablePath, ["version"]).pipe( - wrapInstallFailure("validation_failed", "The downloaded relay client binary did not run."), - ); + // Requiring the pinned version here keeps a mislabelled asset from being + // activated, ignored by resolve, and downloaded again on every reconcile. + const installedVersion = yield* probeVersion(executablePath); + if (installedVersion !== CLOUDFLARED_VERSION) { + return yield* new RelayClientInstallError({ + reason: "validation_failed", + message: + installedVersion === null + ? "The downloaded relay client binary did not run." + : `The downloaded relay client reports version ${installedVersion}, not ${CLOUDFLARED_VERSION}.`, + }); + } const stagedPath = `${managedPath}.${yield* crypto.randomUUIDv4}.tmp`; yield* report("activating"); @@ -468,7 +616,7 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function ); const install = installWithProgress(() => Effect.void); - return RelayClient.of({ resolve, install, installWithProgress }); + return RelayClient.of({ resolve, install, installWithProgress, pruneManagedVersions }); }); export const layerCloudflared = (options: CloudflaredRelayClientOptions) =>