authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-05 14:41:25-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-05 14:41:25-04:00
log60e1b73948debac845c3dc72aac35c9adbd4cd64
treeebbc026be22b9da9b3170a0caa49ba2352a10dc8
parent2c301fd0c4fc58c1612be47c3856fa4ad547429d
signature Signed by PGP key B5690EEEBB952194

fix(desktop): separate LAN and Tailscale pairing endpoints (#9882)


3 files changed, 49 insertions(+), 11 deletions(-)

apps/desktop/src/app/DesktopApp.ts+4-1
...@@ -194,7 +194,10 @@ const bootstrap = Effect.gen(function* () {...@@ -194,7 +194,10 @@ const bootstrap = Effect.gen(function* () {
194 yield* logBootstrapInfo("bootstrap enabled network access", {194 yield* logBootstrapInfo("bootstrap enabled network access", {
195 endpointUrl: serverExposureState.endpointUrl,195 endpointUrl: serverExposureState.endpointUrl,
196 });196 });
197 } else if (settings.serverExposureMode === "network-accessible") {197 } else if (
198 settings.serverExposureMode === "network-accessible" &&
199 serverExposureState.mode === "local-only"
200 ) {
198 yield* logBootstrapWarning(201 yield* logBootstrapWarning(
199 "bootstrap fell back to local-only because no advertised network host was available",202 "bootstrap fell back to local-only because no advertised network host was available",
200 );203 );
apps/desktop/src/backend/DesktopServerExposure.test.ts+33-7
...@@ -304,9 +304,9 @@ describe("DesktopServerExposure", () => {...@@ -304,9 +304,9 @@ describe("DesktopServerExposure", () => {
304 );304 );
305 });305 });
306306
307 it.effect("resolves advertised endpoints from the scoped runtime state", () =>307 it.effect("keeps LAN and Tailscale endpoints distinct when Tailscale is enumerated first", () =>
308 withHarness(308 withHarness(
309 { ...lanNetworkInterfaces, ...tailnetNetworkInterfaces },309 { ...tailnetNetworkInterfaces, ...lanNetworkInterfaces },
310 Effect.gen(function* () {310 Effect.gen(function* () {
311 const serverExposure = yield* DesktopServerExposure.DesktopServerExposure;311 const serverExposure = yield* DesktopServerExposure.DesktopServerExposure;
312 yield* serverExposure.configureFromSettings({ port: 4173 });312 yield* serverExposure.configureFromSettings({ port: 4173 });
...@@ -321,6 +321,32 @@ describe("DesktopServerExposure", () => {...@@ -321,6 +321,32 @@ describe("DesktopServerExposure", () => {
321 ),321 ),
322 );322 );
323323
324 it.effect("keeps Tailscale-only hosts network-accessible", () =>
325 withHarness(
326 tailnetNetworkInterfaces,
327 Effect.gen(function* () {
328 const serverExposure = yield* DesktopServerExposure.DesktopServerExposure;
329 const settings = yield* DesktopAppSettings.DesktopAppSettings;
330 yield* settings.setServerExposureMode("network-accessible");
331
332 const state = yield* serverExposure.configureFromSettings({ port: 4173 });
333 assert.equal(state.mode, "network-accessible");
334 assert.equal(state.advertisedHost, null);
335 assert.equal(state.endpointUrl, null);
336 assert.equal((yield* serverExposure.backendConfig).bindHost, "0.0.0.0");
337
338 const endpoints = yield* serverExposure.getAdvertisedEndpoints;
339 assert.deepEqual(
340 endpoints.map((endpoint) => [endpoint.reachability, endpoint.httpBaseUrl]),
341 [
342 ["loopback", "http://127.0.0.1:4173/"],
343 ["private-network", "http://100.90.1.2:4173/"],
344 ],
345 );
346 }),
347 ),
348 );
349
324 it.effect("does not spawn the tailscale CLI while server exposure is local-only", () =>350 it.effect("does not spawn the tailscale CLI while server exposure is local-only", () =>
325 withHarness(351 withHarness(
326 lanNetworkInterfaces,352 lanNetworkInterfaces,
...@@ -342,7 +368,7 @@ describe("DesktopServerExposure", () => {...@@ -342,7 +368,7 @@ describe("DesktopServerExposure", () => {
342 ),368 ),
343 );369 );
344370
345 it.effect("uses ConfigProvider desktop exposure overrides", () =>371 it.effect("preserves explicit Tailscale exposure overrides", () =>
346 withHarness(372 withHarness(
347 lanNetworkInterfaces,373 lanNetworkInterfaces,
348 Effect.gen(function* () {374 Effect.gen(function* () {
...@@ -350,17 +376,17 @@ describe("DesktopServerExposure", () => {...@@ -350,17 +376,17 @@ describe("DesktopServerExposure", () => {
350 yield* serverExposure.configureFromSettings({ port: 4173 });376 yield* serverExposure.configureFromSettings({ port: 4173 });
351 const change = yield* serverExposure.setMode("network-accessible");377 const change = yield* serverExposure.setMode("network-accessible");
352378
353 assert.equal(change.state.advertisedHost, "10.0.0.7");379 assert.equal(change.state.advertisedHost, "100.90.1.2");
354 assert.equal(change.state.endpointUrl, "http://10.0.0.7:4173");380 assert.equal(change.state.endpointUrl, "http://100.90.1.2:4173");
355381
356 const endpoints = yield* serverExposure.getAdvertisedEndpoints;382 const endpoints = yield* serverExposure.getAdvertisedEndpoints;
357 assert.deepEqual(383 assert.deepEqual(
358 endpoints.map((endpoint) => endpoint.httpBaseUrl),384 endpoints.map((endpoint) => endpoint.httpBaseUrl),
359 ["http://127.0.0.1:4173/", "http://10.0.0.7:4173/", "https://public.example.test/"],385 ["http://127.0.0.1:4173/", "http://100.90.1.2:4173/", "https://public.example.test/"],
360 );386 );
361 }),387 }),
362 {388 {
363 T3CODE_DESKTOP_LAN_HOST: "10.0.0.7",389 T3CODE_DESKTOP_LAN_HOST: "100.90.1.2",
364 T3CODE_DESKTOP_HTTPS_ENDPOINTS: "https://public.example.test",390 T3CODE_DESKTOP_HTTPS_ENDPOINTS: "https://public.example.test",
365 },391 },
366 ),392 ),
apps/desktop/src/backend/DesktopServerExposure.ts+12-3
...@@ -9,7 +9,7 @@ import {...@@ -9,7 +9,7 @@ import {
9 type DesktopServerExposureMode,9 type DesktopServerExposureMode,
10 type DesktopServerExposureState,10 type DesktopServerExposureState,
11} from "@t3tools/contracts";11} from "@t3tools/contracts";
12import { readTailscaleStatus } from "@t3tools/tailscale";12import { isTailscaleIpv4Address, readTailscaleStatus } from "@t3tools/tailscale";
13import * as Context from "effect/Context";13import * as Context from "effect/Context";
14import * as Duration from "effect/Duration";14import * as Duration from "effect/Duration";
15import * as Effect from "effect/Effect";15import * as Effect from "effect/Effect";
...@@ -65,7 +65,9 @@ const normalizeOptionalHost = (value: string | undefined): string | undefined =>...@@ -65,7 +65,9 @@ const normalizeOptionalHost = (value: string | undefined): string | undefined =>
65};65};
6666
67const isUsableLanIpv4Address = (address: string): boolean =>67const isUsableLanIpv4Address = (address: string): boolean =>
68 !address.startsWith("127.") && !address.startsWith("169.254.");68 !address.startsWith("127.") &&
69 !address.startsWith("169.254.") &&
70 !isTailscaleIpv4Address(address);
6971
70const isHttpsEndpointUrl = (value: string): boolean => {72const isHttpsEndpointUrl = (value: string): boolean => {
71 try {73 try {
...@@ -376,7 +378,14 @@ function resolveRuntimeState(input: {...@@ -376,7 +378,14 @@ function resolveRuntimeState(input: {
376 ...(advertisedHostOverride ? { advertisedHostOverride } : {}),378 ...(advertisedHostOverride ? { advertisedHostOverride } : {}),
377 });379 });
378 const unavailable =380 const unavailable =
379 input.requestedMode === "network-accessible" && requestedExposure.endpointUrl === null;381 input.requestedMode === "network-accessible" &&
382 requestedExposure.endpointUrl === null &&
383 !Object.values(input.networkInterfaces).some((addresses) =>
384 addresses?.some(
385 (address) =>
386 !address.internal && address.family === "IPv4" && isTailscaleIpv4Address(address.address),
387 ),
388 );
380 const exposure = unavailable389 const exposure = unavailable
381 ? resolveDesktopServerExposure({390 ? resolveDesktopServerExposure({
382 mode: "local-only",391 mode: "local-only",