diff --git a/apps/desktop/src/backendPort.test.ts b/apps/desktop/src/backendPort.test.ts index 57a066eb58..8f586deb70 100644 --- a/apps/desktop/src/backendPort.test.ts +++ b/apps/desktop/src/backendPort.test.ts @@ -36,6 +36,57 @@ describe("resolveDesktopBackendPort", () => { ]); }); + it("treats wildcard-bound ports as unavailable even when loopback probing succeeds", async () => { + const canListenOnHost = vi.fn(async (port: number, host: string) => { + if (port === 3773 && host === "127.0.0.1") return true; + if (port === 3773 && host === "0.0.0.0") return false; + return port === 3774; + }); + + await expect( + resolveDesktopBackendPort({ + host: "127.0.0.1", + requiredHosts: ["0.0.0.0"], + startPort: 3773, + canListenOnHost, + }), + ).resolves.toBe(3774); + + expect(canListenOnHost.mock.calls).toEqual([ + [3773, "127.0.0.1"], + [3773, "0.0.0.0"], + [3774, "127.0.0.1"], + [3774, "0.0.0.0"], + ]); + }); + + it("checks overlapping hosts sequentially to avoid self-interference", async () => { + let inFlightCount = 0; + const canListenOnHost = vi.fn(async (_port: number, _host: string) => { + inFlightCount += 1; + const overlapped = inFlightCount > 1; + await Promise.resolve(); + inFlightCount -= 1; + return !overlapped; + }); + + await expect( + resolveDesktopBackendPort({ + host: "127.0.0.1", + requiredHosts: ["0.0.0.0", "::"], + startPort: 3773, + maxPort: 3773, + canListenOnHost, + }), + ).resolves.toBe(3773); + + expect(canListenOnHost.mock.calls).toEqual([ + [3773, "127.0.0.1"], + [3773, "0.0.0.0"], + [3773, "::"], + ]); + }); + it("fails when the scan range is exhausted", async () => { const canListenOnHost = vi.fn(async () => false); @@ -46,7 +97,9 @@ describe("resolveDesktopBackendPort", () => { maxPort: 65535, canListenOnHost, }), - ).rejects.toThrow("No desktop backend port is available on 127.0.0.1 between 65534 and 65535"); + ).rejects.toThrow( + "No desktop backend port is available on hosts 127.0.0.1 between 65534 and 65535", + ); expect(canListenOnHost.mock.calls).toEqual([ [65534, "127.0.0.1"], diff --git a/apps/desktop/src/backendPort.ts b/apps/desktop/src/backendPort.ts index e70272c397..1ce90a257f 100644 --- a/apps/desktop/src/backendPort.ts +++ b/apps/desktop/src/backendPort.ts @@ -8,6 +8,7 @@ export interface ResolveDesktopBackendPortOptions { readonly host: string; readonly startPort?: number; readonly maxPort?: number; + readonly requiredHosts?: ReadonlyArray; readonly canListenOnHost?: (port: number, host: string) => Promise; } @@ -21,10 +22,37 @@ const defaultCanListenOnHost = async (port: number, host: string): Promise Number.isInteger(port) && port >= 1 && port <= MAX_TCP_PORT; +const normalizeHosts = ( + host: string, + requiredHosts: ReadonlyArray, +): ReadonlyArray => + Array.from( + new Set( + [host, ...requiredHosts] + .map((candidate) => candidate.trim()) + .filter((candidate) => candidate.length > 0), + ), + ); + +async function canListenOnAllHosts( + port: number, + hosts: ReadonlyArray, + canListenOnHost: (port: number, host: string) => Promise, +): Promise { + for (const candidateHost of hosts) { + if (!(await canListenOnHost(port, candidateHost))) { + return false; + } + } + + return true; +} + export async function resolveDesktopBackendPort({ host, startPort = DEFAULT_DESKTOP_BACKEND_PORT, maxPort = MAX_TCP_PORT, + requiredHosts = [], canListenOnHost = defaultCanListenOnHost, }: ResolveDesktopBackendPortOptions): Promise { if (!isValidPort(startPort)) { @@ -39,15 +67,17 @@ export async function resolveDesktopBackendPort({ throw new Error(`Desktop backend max port ${maxPort} is below start port ${startPort}`); } + const hostsToCheck = normalizeHosts(host, requiredHosts); + // Keep desktop startup predictable across app restarts by probing upward from // the same preferred port instead of picking a fresh ephemeral port. for (let port = startPort; port <= maxPort; port += 1) { - if (await canListenOnHost(port, host)) { + if (await canListenOnAllHosts(port, hostsToCheck, canListenOnHost)) { return port; } } throw new Error( - `No desktop backend port is available on ${host} between ${startPort} and ${maxPort}`, + `No desktop backend port is available on hosts ${hostsToCheck.join(", ")} between ${startPort} and ${maxPort}`, ); } diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 310b973458..0ce03aeace 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -117,6 +117,7 @@ const AUTO_UPDATE_POLL_INTERVAL_MS = 4 * 60 * 60 * 1000; const DESKTOP_UPDATE_CHANNEL = "latest"; const DESKTOP_UPDATE_ALLOW_PRERELEASE = false; const DESKTOP_LOOPBACK_HOST = "127.0.0.1"; +const DESKTOP_REQUIRED_PORT_PROBE_HOSTS = ["0.0.0.0", "::"] as const; type DesktopUpdateErrorContext = DesktopUpdateState["errorContext"]; type LinuxDesktopNamedApp = Electron.App & { @@ -1773,6 +1774,7 @@ async function bootstrap(): Promise { (await resolveDesktopBackendPort({ host: DESKTOP_LOOPBACK_HOST, startPort: DEFAULT_DESKTOP_BACKEND_PORT, + requiredHosts: DESKTOP_REQUIRED_PORT_PROBE_HOSTS, })); writeDesktopLogHeader( configuredBackendPort === undefined diff --git a/apps/server/src/auth/Layers/ServerAuth.ts b/apps/server/src/auth/Layers/ServerAuth.ts index fa7191110a..cb1c6fa4c4 100644 --- a/apps/server/src/auth/Layers/ServerAuth.ts +++ b/apps/server/src/auth/Layers/ServerAuth.ts @@ -20,7 +20,10 @@ import { AuthError, type ServerAuthShape, } from "../Services/ServerAuth.ts"; -import { SessionCredentialService } from "../Services/SessionCredentialService.ts"; +import { + SessionCredentialError, + SessionCredentialService, +} from "../Services/SessionCredentialService.ts"; import { AuthControlPlaneLive, AuthCoreLive } from "./AuthControlPlane.ts"; type BootstrapExchangeResult = { @@ -65,6 +68,13 @@ export const makeServerAuth = Effect.gen(function* () { const authenticateToken = (token: string): Effect.Effect => sessions.verify(token).pipe( + Effect.tapError((cause: SessionCredentialError) => + Effect.logWarning("Rejected authenticated session credential.").pipe( + Effect.annotateLogs({ + reason: cause.message, + }), + ), + ), Effect.map((session) => ({ sessionId: session.sessionId, subject: session.subject, diff --git a/apps/server/src/auth/Layers/ServerAuthPolicy.test.ts b/apps/server/src/auth/Layers/ServerAuthPolicy.test.ts index 640cc030f8..13ca0233ee 100644 --- a/apps/server/src/auth/Layers/ServerAuthPolicy.test.ts +++ b/apps/server/src/auth/Layers/ServerAuthPolicy.test.ts @@ -33,10 +33,12 @@ it.layer(NodeServices.layer)("ServerAuthPolicyLive", (it) => { expect(descriptor.policy).toBe("desktop-managed-local"); expect(descriptor.bootstrapMethods).toEqual(["desktop-bootstrap"]); + expect(descriptor.sessionCookieName).toBe("t3_session_3773"); }).pipe( Effect.provide( makeServerAuthPolicyLayer({ mode: "desktop", + port: 3773, }), ), ), @@ -66,6 +68,7 @@ it.layer(NodeServices.layer)("ServerAuthPolicyLive", (it) => { expect(descriptor.policy).toBe("loopback-browser"); expect(descriptor.bootstrapMethods).toEqual(["one-time-token"]); + expect(descriptor.sessionCookieName).toBe("t3_session"); }).pipe( Effect.provide( makeServerAuthPolicyLayer({ diff --git a/apps/server/src/auth/Layers/ServerAuthPolicy.ts b/apps/server/src/auth/Layers/ServerAuthPolicy.ts index 9f952cc9ec..43735b4761 100644 --- a/apps/server/src/auth/Layers/ServerAuthPolicy.ts +++ b/apps/server/src/auth/Layers/ServerAuthPolicy.ts @@ -3,7 +3,7 @@ import { Effect, Layer } from "effect"; import { ServerConfig } from "../../config.ts"; import { ServerAuthPolicy, type ServerAuthPolicyShape } from "../Services/ServerAuthPolicy.ts"; -import { SESSION_COOKIE_NAME } from "../utils.ts"; +import { resolveSessionCookieName } from "../utils.ts"; import { isLoopbackHost, isWildcardHost } from "../../startupAccess.ts"; export const makeServerAuthPolicy = Effect.gen(function* () { @@ -30,7 +30,10 @@ export const makeServerAuthPolicy = Effect.gen(function* () { policy, bootstrapMethods, sessionMethods: ["browser-session-cookie", "bearer-session-token"], - sessionCookieName: SESSION_COOKIE_NAME, + sessionCookieName: resolveSessionCookieName({ + mode: config.mode, + port: config.port, + }), }; return { diff --git a/apps/server/src/auth/Layers/SessionCredentialService.ts b/apps/server/src/auth/Layers/SessionCredentialService.ts index 53b6ae2abf..c1ca279286 100644 --- a/apps/server/src/auth/Layers/SessionCredentialService.ts +++ b/apps/server/src/auth/Layers/SessionCredentialService.ts @@ -2,10 +2,10 @@ import { AuthSessionId, type AuthClientMetadata, type AuthClientSession } from " import { Clock, DateTime, Duration, Effect, Layer, PubSub, Ref, Schema, Stream } from "effect"; import { Option } from "effect"; +import { ServerConfig } from "../../config.ts"; import { AuthSessionRepositoryLive } from "../../persistence/Layers/AuthSessions.ts"; import { AuthSessionRepository } from "../../persistence/Services/AuthSessions.ts"; import { ServerSecretStore } from "../Services/ServerSecretStore.ts"; -import { SESSION_COOKIE_NAME } from "../utils.ts"; import { SessionCredentialError, SessionCredentialService, @@ -17,6 +17,7 @@ import { import { base64UrlDecodeUtf8, base64UrlEncode, + resolveSessionCookieName, signPayload, timingSafeEqualBase64Url, } from "../utils.ts"; @@ -81,11 +82,16 @@ function toAuthClientSession(input: Omit): AuthCli } export const makeSessionCredentialService = Effect.gen(function* () { + const serverConfig = yield* ServerConfig; const secretStore = yield* ServerSecretStore; const authSessions = yield* AuthSessionRepository; const signingSecret = yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32); const connectedSessionsRef = yield* Ref.make(new Map()); const changesPubSub = yield* PubSub.unbounded(); + const cookieName = resolveSessionCookieName({ + mode: serverConfig.mode, + port: serverConfig.port, + }); const toSessionCredentialError = (message: string) => (cause: unknown) => new SessionCredentialError({ @@ -472,7 +478,7 @@ export const makeSessionCredentialService = Effect.gen(function* () { }).pipe(Effect.mapError(toSessionCredentialError("Failed to revoke other sessions."))); return { - cookieName: SESSION_COOKIE_NAME, + cookieName, issue, verify, issueWebSocketToken, diff --git a/apps/server/src/auth/utils.ts b/apps/server/src/auth/utils.ts index e87c66c6b9..2c76a81f65 100644 --- a/apps/server/src/auth/utils.ts +++ b/apps/server/src/auth/utils.ts @@ -2,7 +2,18 @@ import type { AuthClientMetadata, AuthClientMetadataDeviceType } from "@t3tools/ import type * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; import * as Crypto from "node:crypto"; -export const SESSION_COOKIE_NAME = "t3_session"; +const SESSION_COOKIE_NAME = "t3_session"; + +export function resolveSessionCookieName(input: { + readonly mode: "web" | "desktop"; + readonly port: number; +}): string { + if (input.mode !== "desktop") { + return SESSION_COOKIE_NAME; + } + + return `${SESSION_COOKIE_NAME}_${input.port}`; +} export function base64UrlEncode(input: string | Uint8Array): string { const buffer = typeof input === "string" ? Buffer.from(input, "utf8") : Buffer.from(input); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 98e1395e13..c3543294ee 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -789,7 +789,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { "browser-session-cookie", "bearer-session-token", ]); - assert.equal(body.auth.sessionCookieName, "t3_session"); + assert.isTrue(body.auth.sessionCookieName.startsWith("t3_session_")); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); diff --git a/scripts/dev-runner.test.ts b/scripts/dev-runner.test.ts index 0918e6986a..41dd4d462f 100644 --- a/scripts/dev-runner.test.ts +++ b/scripts/dev-runner.test.ts @@ -5,6 +5,7 @@ import { assert, describe, it } from "@effect/vitest"; import { Effect } from "effect"; import { + checkPortAvailabilityOnHosts, createDevRunnerEnv, findFirstAvailableOffset, resolveModePortOffsets, @@ -162,11 +163,11 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { const env = yield* createDevRunnerEnv({ mode: "dev:desktop", baseEnv: { - T3CODE_PORT: "3773", + T3CODE_PORT: "13773", T3CODE_MODE: "web", T3CODE_NO_BROWSER: "0", T3CODE_HOST: "0.0.0.0", - VITE_WS_URL: "ws://localhost:3773", + VITE_WS_URL: "ws://localhost:13773", }, serverOffset: 0, webOffset: 0, @@ -191,6 +192,28 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { assert.equal(env.VITE_WS_URL, "ws://127.0.0.1:4222"); }), ); + + it.effect("defaults dev server mode to the higher backend port range", () => + Effect.gen(function* () { + const env = yield* createDevRunnerEnv({ + mode: "dev", + baseEnv: {}, + serverOffset: 0, + webOffset: 0, + t3Home: undefined, + noBrowser: undefined, + autoBootstrapProjectFromCwd: undefined, + logWebSocketEvents: undefined, + host: undefined, + port: undefined, + devUrl: undefined, + }); + + assert.equal(env.T3CODE_PORT, "13773"); + assert.equal(env.VITE_HTTP_URL, "http://localhost:13773"); + assert.equal(env.VITE_WS_URL, "ws://localhost:13773"); + }), + ); }); describe("findFirstAvailableOffset", () => { @@ -209,7 +232,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { it.effect("advances until all required ports are available", () => Effect.gen(function* () { - const taken = new Set([3773, 5733, 3774, 5734]); + const taken = new Set([13773, 5733, 13774, 5734]); const offset = yield* findFirstAvailableOffset({ startOffset: 0, requireServerPort: true, @@ -221,16 +244,46 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { }), ); - it.effect("allows offsets where only non-required ports exceed max", () => + it.effect("allows offsets where the non-required server port exceeds max", () => Effect.gen(function* () { const offset = yield* findFirstAvailableOffset({ - startOffset: 59_803, - requireServerPort: true, - requireWebPort: false, + startOffset: 59_802, + requireServerPort: false, + requireWebPort: true, checkPortAvailability: () => Effect.succeed(true), }); - assert.equal(offset, 59_803); + assert.equal(offset, 59_802); + }), + ); + }); + + describe("checkPortAvailabilityOnHosts", () => { + it.effect("checks overlapping hosts sequentially to avoid self-interference", () => + Effect.gen(function* () { + let inFlightCount = 0; + const calls: Array<[number, string]> = []; + + const available = yield* checkPortAvailabilityOnHosts( + 13_773, + ["127.0.0.1", "0.0.0.0", "::"], + (port, host) => + Effect.promise(async () => { + calls.push([port, host]); + inFlightCount += 1; + const overlapped = inFlightCount > 1; + await Promise.resolve(); + inFlightCount -= 1; + return !overlapped; + }), + ); + + assert.equal(available, true); + assert.deepStrictEqual(calls, [ + [13_773, "127.0.0.1"], + [13_773, "0.0.0.0"], + [13_773, "::"], + ]); }), ); }); @@ -238,7 +291,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { describe("resolveModePortOffsets", () => { it.effect("uses a shared fallback offset for dev mode", () => Effect.gen(function* () { - const taken = new Set([3773, 5733]); + const taken = new Set([13773, 5733]); const offsets = yield* resolveModePortOffsets({ mode: "dev", startOffset: 0, @@ -268,7 +321,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { it.effect("shifts only server offset for dev:server", () => Effect.gen(function* () { - const taken = new Set([3773]); + const taken = new Set([13773]); const offsets = yield* resolveModePortOffsets({ mode: "dev:server", startOffset: 0, diff --git a/scripts/dev-runner.ts b/scripts/dev-runner.ts index 4522c21d70..59f9f15b3b 100644 --- a/scripts/dev-runner.ts +++ b/scripts/dev-runner.ts @@ -9,11 +9,12 @@ import { Config, Data, Effect, Hash, Layer, Logger, Option, Path, Schema } from import { Argument, Command, Flag } from "effect/unstable/cli"; import { ChildProcess } from "effect/unstable/process"; -const BASE_SERVER_PORT = 3773; +const BASE_SERVER_PORT = 13773; const BASE_WEB_PORT = 5733; const MAX_HASH_OFFSET = 3000; const MAX_PORT = 65535; const DESKTOP_DEV_LOOPBACK_HOST = "127.0.0.1"; +const DEV_PORT_PROBE_HOSTS = ["127.0.0.1", "0.0.0.0", "::1", "::"] as const; export const DEFAULT_T3_HOME = Effect.map(Effect.service(Path.Path), (path) => path.join(homedir(), ".t3"), @@ -221,10 +222,28 @@ function portPairForOffset(offset: number): { }; } +export function checkPortAvailabilityOnHosts( + port: number, + hosts: ReadonlyArray, + canListenOnHost: (port: number, host: string) => Effect.Effect, +): Effect.Effect { + return Effect.gen(function* () { + for (const host of hosts) { + if (!(yield* canListenOnHost(port, host))) { + return false; + } + } + + return true; + }); +} + const defaultCheckPortAvailability: PortAvailabilityCheck = (port) => Effect.gen(function* () { const net = yield* NetService; - return yield* net.isPortAvailableOnLoopback(port); + return yield* checkPortAvailabilityOnHosts(port, DEV_PORT_PROBE_HOSTS, (candidatePort, host) => + net.canListenOnHost(candidatePort, host), + ); }); interface FindFirstAvailableOffsetInput {