diff --git a/apps/server/src/cli/config.test.ts b/apps/server/src/cli/config.test.ts index 5e09dda2c164f..395e580ac30d6 100644 --- a/apps/server/src/cli/config.test.ts +++ b/apps/server/src/cli/config.test.ts @@ -919,7 +919,11 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { readonly env?: Record; }) => resolveServerConfig(minimalWebFlags(baseDir), Option.none()).pipe( - Effect.map(({ host, privateNetworkOnly }) => ({ host, privateNetworkOnly })), + Effect.map(({ host, privateNetworkOnly, loopbackFallback }) => ({ + host, + privateNetworkOnly, + loopbackFallback, + })), Effect.provide( Layer.mergeAll( ConfigProvider.layer(ConfigProvider.fromEnv({ env: input.env ?? {} })), @@ -944,25 +948,30 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { expect(yield* resolveHost({ connected: true })).toEqual({ host: "0.0.0.0", privateNetworkOnly: true, + loopbackFallback: true, }); // `service.env` reaches the server as its environment. expect(yield* resolveHost({ connected: true, env: { T3CODE_HOST: "127.0.0.1" } })).toEqual({ host: "127.0.0.1", privateNetworkOnly: undefined, + loopbackFallback: undefined, }); expect(yield* resolveHost({ connected: true, env: { T3CODE_LAN: "false" } })).toEqual({ host: undefined, privateNetworkOnly: undefined, + loopbackFallback: undefined, }); expect(yield* resolveHost({ connected: false, env: { T3CODE_LAN: "true" } })).toEqual({ host: "0.0.0.0", privateNetworkOnly: true, + loopbackFallback: undefined, }); // Without the launcher's IPC channel the context was inherited, e.g. by // `t3 serve` started from an agent inside the service. expect(yield* resolveHost({ connected: false })).toEqual({ host: undefined, privateNetworkOnly: undefined, + loopbackFallback: undefined, }); }), ); diff --git a/apps/server/src/cli/config.ts b/apps/server/src/cli/config.ts index 00aae0f3133b7..b994e62ed6365 100644 --- a/apps/server/src/cli/config.ts +++ b/apps/server/src/cli/config.ts @@ -505,6 +505,7 @@ export const resolveServerConfig = Effect.fn("config.resolveServerConfig")(funct serverTracePath, host, ...(privateNetworkOnly ? { privateNetworkOnly } : {}), + ...(privateNetworkOnly && Option.isNone(lan) ? { loopbackFallback: true } : {}), staticDir, devUrl, ...(devAuthToken === undefined ? {} : { devAuthToken }), diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index d4883688c08ef..48a8e60374ea6 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -87,6 +87,8 @@ export class ServerConfig extends Context.Service< readonly host: string | undefined; /** Set when the host was defaulted to a private-network listener for the service. */ readonly privateNetworkOnly?: boolean | undefined; + /** The service picked that listener without `--lan`, so a taken port may fall back to loopback. */ + readonly loopbackFallback?: boolean | undefined; readonly cwd: string; readonly baseDir: string; readonly staticDir: string | undefined; diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts new file mode 100644 index 0000000000000..de0b11e28bd9c --- /dev/null +++ b/apps/server/src/server.test.ts @@ -0,0 +1,106 @@ +import { assert, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as NetService from "@t3tools/shared/Net"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import { HttpServer, HttpServerError } from "effect/http"; +import * as NodeNet from "node:net"; +import * as NodeOS from "node:os"; + +import * as ServerConfig from "./config.ts"; +import { layerHttpServer } from "./server.ts"; + +// Linux refuses a wildcard bind on a port one specific address holds; macOS +// lets it share the port under SO_REUSEADDR, so there is nothing to recover. +const nonLoopbackAddress = + HostProcess.Platform.defaultValue() === "linux" + ? Object.values(NodeOS.networkInterfaces()) + .flat() + .find((entry) => entry?.family === "IPv4" && !entry.internal)?.address + : undefined; + +const occupyNonLoopbackPort = (address: string) => + Effect.acquireRelease( + Effect.callback((resume) => { + const blocker = NodeNet.createServer(); + blocker.once("error", (error) => resume(Effect.die(error))); + blocker.listen(0, address, () => { + const bound = blocker.address() as NodeNet.AddressInfo; + resume(Effect.succeed(Object.assign(blocker, { port: bound.port }))); + }); + }), + (blocker) => Effect.callback((resume) => void blocker.close(() => resume(Effect.void))), + ); + +const httpServerWithConfig = ( + overrides: Pick< + ServerConfig.ServerConfig["Service"], + "host" | "port" | "privateNetworkOnly" | "loopbackFallback" + >, +) => + layerHttpServer.pipe( + Layer.provide( + Layer.effect( + ServerConfig.ServerConfig, + Effect.map(ServerConfig.ServerConfig, (config) => ({ ...config, ...overrides })), + ).pipe( + Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-server-bind-test-" })), + ), + ), + Layer.provide(Layer.mergeAll(NodeServices.layer, NetService.layer)), + ); + +const live = it.live.skipIf(nonLoopbackAddress === undefined); + +live("managed service falls back to loopback when its wildcard port is taken", () => + Effect.scoped( + Effect.gen(function* () { + const { port } = yield* occupyNonLoopbackPort(nonLoopbackAddress!); + const { address, config } = yield* Effect.all({ + address: Effect.map(HttpServer.HttpServer, (server) => server.address), + config: ServerConfig.ServerConfig, + }).pipe( + Effect.provide( + httpServerWithConfig({ + host: "0.0.0.0", + port, + privateNetworkOnly: true, + loopbackFallback: true, + }), + ), + ); + assert.strictEqual(config.host, "127.0.0.1"); + const listening = + address._tag === "InetAddressV4" + ? `${String(address.address)}:${address.port}` + : address._tag; + assert.strictEqual(listening, `127.0.0.1:${port}`); + }), + ), +); + +live("--lan still fails when its wildcard port is taken", () => + Effect.scoped( + Effect.gen(function* () { + const { port } = yield* occupyNonLoopbackPort(nonLoopbackAddress!); + const exit = yield* HttpServer.HttpServer.pipe( + Effect.provide( + httpServerWithConfig({ + host: "0.0.0.0", + port, + privateNetworkOnly: true, + loopbackFallback: undefined, + }), + ), + Effect.exit, + ); + assert.isTrue(Exit.isFailure(exit)); + const error = Exit.isFailure(exit) ? Cause.squash(exit.cause) : undefined; + assert.instanceOf(error, HttpServerError.ServeError); + assert.propertyVal(error.cause, "code", "EADDRINUSE"); + }), + ), +); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 47190f60dcf51..1bf8b9ba24521 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -337,7 +337,10 @@ const layerRelayClient = Layer.unwrap( }), ).pipe(Layer.provide(ManagedPolicy.layer)); -const layerHttpServer = Layer.unwrap( +const isAddressInUse = (cause: unknown) => + typeof cause === "object" && cause !== null && "code" in cause && cause.code === "EADDRINUSE"; + +export const layerHttpServer = Layer.unwrap( Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; // Before binding: a host the policy does not allow must never listen, not even briefly. @@ -349,30 +352,63 @@ const layerHttpServer = Layer.unwrap( privateNetworkOnly: config.privateNetworkOnly, }); yield* ManagedLimits.closeTailscaleServeTo(policy, { host: bindHost, port: config.port }); - return NodeHttpServer.layer( - () => { - const server = rejectRequestsUntilServing( - guardHttpResponseWriteErrors(NodeHttp.createServer()), - ); - if (config.privateNetworkOnly) { - server.on("connection", (socket) => { - if (!acceptsPrivateNetworkConnection(socket)) { - socket.destroy(); - } - }); - } - return server; - }, - { - host: bindHost, - port: config.port, - gracefulShutdownTimeout: HTTP_PREEMPTIVE_SHUTDOWN_GRACE_MS, - // Context takeover stays enabled (ws default) so the compression - // window is shared across frames — that also makes small frames cheap - // to compress, so no size threshold is set (ws only honors - // `threshold` when context takeover is disabled). - websocket: { perMessageDeflate: true }, - }, + const listen = (host: string) => + NodeHttpServer.layer( + () => { + const server = rejectRequestsUntilServing( + guardHttpResponseWriteErrors(NodeHttp.createServer()), + ); + if (config.privateNetworkOnly) { + server.on("connection", (socket) => { + if (!acceptsPrivateNetworkConnection(socket)) { + socket.destroy(); + } + }); + } + return server; + }, + { + host, + port: config.port, + gracefulShutdownTimeout: HTTP_PREEMPTIVE_SHUTDOWN_GRACE_MS, + // Context takeover stays enabled (ws default) so the compression + // window is shared across frames — that also makes small frames cheap + // to compress, so no size threshold is set (ws only honors + // `threshold` when context takeover is disabled). + websocket: { perMessageDeflate: true }, + }, + ); + const listening = Layer.merge( + listen(bindHost), + Layer.succeed(ServerConfig.ServerConfig, config), + ); + if (!config.loopbackFallback) return listening; + // The service chose the wildcard on its own, so a port held on one interface + // (e.g. by Tailscale Serve) must not cost the local server; layers above see the real host. + return listening.pipe( + Layer.catchTag("ServeError", (error) => + Layer.unwrap( + isAddressInUse(error.cause) + ? Effect.succeed( + Layer.merge( + listen("127.0.0.1").pipe( + Layer.tap(() => + Effect.logWarning( + `Port ${config.port} is taken on another interface; listening on 127.0.0.1 only.`, + ), + ), + ), + Layer.succeed(ServerConfig.ServerConfig, { + ...config, + host: "127.0.0.1", + privateNetworkOnly: undefined, + loopbackFallback: undefined, + }), + ), + ) + : Effect.fail(error), + ), + ), ); }), ).pipe(Layer.provide(ManagedPolicy.layer)); @@ -884,7 +920,7 @@ const layerMakeServer = Layer.unwrap( const launcher = yield* ServiceLauncherClient.ServiceLauncherClient; const state = yield* makePersistedServerRuntimeState({ - config, + config: yield* ServerConfig.ServerConfig, port: address.port, serviceManaged: launcher.managed, managedPolicy: yield* ManagedPolicy.loadedPolicy(yield* ManagedPolicy.ManagedPolicy),