diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index d5a8ac3b7836..d21eefd65f85 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -194,7 +194,10 @@ const bootstrap = Effect.gen(function* () { yield* logBootstrapInfo("bootstrap enabled network access", { endpointUrl: serverExposureState.endpointUrl, }); - } else if (settings.serverExposureMode === "network-accessible") { + } else if ( + settings.serverExposureMode === "network-accessible" && + serverExposureState.mode === "local-only" + ) { yield* logBootstrapWarning( "bootstrap fell back to local-only because no advertised network host was available", ); diff --git a/apps/desktop/src/backend/DesktopServerExposure.test.ts b/apps/desktop/src/backend/DesktopServerExposure.test.ts index dcfee93778d1..a8a929f35744 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.test.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.test.ts @@ -307,9 +307,9 @@ describe("DesktopServerExposure", () => { ); }); - it.effect("resolves advertised endpoints from the scoped runtime state", () => + it.effect("keeps LAN and Tailscale endpoints distinct when Tailscale is enumerated first", () => withHarness( - { ...lanNetworkInterfaces, ...tailnetNetworkInterfaces }, + { ...tailnetNetworkInterfaces, ...lanNetworkInterfaces }, Effect.gen(function* () { const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; yield* serverExposure.configureFromSettings({ port: 4173 }); @@ -324,6 +324,32 @@ describe("DesktopServerExposure", () => { ), ); + it.effect("keeps Tailscale-only hosts network-accessible", () => + withHarness( + tailnetNetworkInterfaces, + Effect.gen(function* () { + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + const settings = yield* DesktopAppSettings.DesktopAppSettings; + yield* settings.setServerExposureMode("network-accessible"); + + const state = yield* serverExposure.configureFromSettings({ port: 4173 }); + assert.equal(state.mode, "network-accessible"); + assert.equal(state.advertisedHost, null); + assert.equal(state.endpointUrl, null); + assert.equal((yield* serverExposure.backendConfig).bindHost, "0.0.0.0"); + + const endpoints = yield* serverExposure.getAdvertisedEndpoints; + assert.deepEqual( + endpoints.map((endpoint) => [endpoint.reachability, endpoint.httpBaseUrl]), + [ + ["loopback", "http://127.0.0.1:4173/"], + ["private-network", "http://100.90.1.2:4173/"], + ], + ); + }), + ), + ); + it.effect("does not spawn the tailscale CLI while server exposure is local-only", () => withHarness( lanNetworkInterfaces, @@ -345,7 +371,7 @@ describe("DesktopServerExposure", () => { ), ); - it.effect("uses ConfigProvider desktop exposure overrides", () => + it.effect("preserves explicit Tailscale exposure overrides", () => withHarness( lanNetworkInterfaces, Effect.gen(function* () { @@ -353,17 +379,17 @@ describe("DesktopServerExposure", () => { yield* serverExposure.configureFromSettings({ port: 4173 }); const change = yield* serverExposure.setMode("network-accessible"); - assert.equal(change.state.advertisedHost, "10.0.0.7"); - assert.equal(change.state.endpointUrl, "http://10.0.0.7:4173"); + assert.equal(change.state.advertisedHost, "100.90.1.2"); + assert.equal(change.state.endpointUrl, "http://100.90.1.2:4173"); const endpoints = yield* serverExposure.getAdvertisedEndpoints; assert.deepEqual( endpoints.map((endpoint) => endpoint.httpBaseUrl), - ["http://127.0.0.1:4173/", "http://10.0.0.7:4173/", "https://public.example.test/"], + ["http://127.0.0.1:4173/", "http://100.90.1.2:4173/", "https://public.example.test/"], ); }), { - T3CODE_DESKTOP_LAN_HOST: "10.0.0.7", + T3CODE_DESKTOP_LAN_HOST: "100.90.1.2", T3CODE_DESKTOP_HTTPS_ENDPOINTS: "https://public.example.test", }, ), diff --git a/apps/desktop/src/backend/DesktopServerExposure.ts b/apps/desktop/src/backend/DesktopServerExposure.ts index f04d2af7b1f6..9fd5235016c0 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.ts @@ -9,7 +9,7 @@ import { type DesktopServerExposureMode, type DesktopServerExposureState, } from "@t3tools/contracts"; -import { readTailscaleStatus } from "@t3tools/tailscale"; +import { isTailscaleIpv4Address, readTailscaleStatus } from "@t3tools/tailscale"; import * as Context from "effect/Context"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -65,7 +65,9 @@ const normalizeOptionalHost = (value: string | undefined): string | undefined => }; const isUsableLanIpv4Address = (address: string): boolean => - !address.startsWith("127.") && !address.startsWith("169.254."); + !address.startsWith("127.") && + !address.startsWith("169.254.") && + !isTailscaleIpv4Address(address); const isHttpsEndpointUrl = (value: string): boolean => { try { @@ -378,7 +380,14 @@ function resolveRuntimeState(input: { ...(advertisedHostOverride ? { advertisedHostOverride } : {}), }); const unavailable = - input.requestedMode === "network-accessible" && requestedExposure.endpointUrl === null; + input.requestedMode === "network-accessible" && + requestedExposure.endpointUrl === null && + !Object.values(input.networkInterfaces).some((addresses) => + addresses?.some( + (address) => + !address.internal && address.family === "IPv4" && isTailscaleIpv4Address(address.address), + ), + ); const exposure = unavailable ? resolveDesktopServerExposure({ mode: "local-only",