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* () {
194194 yield* logBootstrapInfo("bootstrap enabled network access", {
195195 endpointUrl: serverExposureState.endpointUrl,
196196 });
197 } else if (settings.serverExposureMode === "network-accessible") {
197 } else if (
198 settings.serverExposureMode === "network-accessible" &&
199 serverExposureState.mode === "local-only"
200 ) {
198201 yield* logBootstrapWarning(
199202 "bootstrap fell back to local-only because no advertised network host was available",
200203 );
apps/desktop/src/backend/DesktopServerExposure.test.ts+33-7
......@@ -304,9 +304,9 @@ describe("DesktopServerExposure", () => {
304304 );
305305 });
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", () =>
308308 withHarness(
309 { ...lanNetworkInterfaces, ...tailnetNetworkInterfaces },
309 { ...tailnetNetworkInterfaces, ...lanNetworkInterfaces },
310310 Effect.gen(function* () {
311311 const serverExposure = yield* DesktopServerExposure.DesktopServerExposure;
312312 yield* serverExposure.configureFromSettings({ port: 4173 });
......@@ -321,6 +321,32 @@ describe("DesktopServerExposure", () => {
321321 ),
322322 );
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
324350 it.effect("does not spawn the tailscale CLI while server exposure is local-only", () =>
325351 withHarness(
326352 lanNetworkInterfaces,
......@@ -342,7 +368,7 @@ describe("DesktopServerExposure", () => {
342368 ),
343369 );
344370
345 it.effect("uses ConfigProvider desktop exposure overrides", () =>
371 it.effect("preserves explicit Tailscale exposure overrides", () =>
346372 withHarness(
347373 lanNetworkInterfaces,
348374 Effect.gen(function* () {
......@@ -350,17 +376,17 @@ describe("DesktopServerExposure", () => {
350376 yield* serverExposure.configureFromSettings({ port: 4173 });
351377 const change = yield* serverExposure.setMode("network-accessible");
352378
353 assert.equal(change.state.advertisedHost, "10.0.0.7");
354 assert.equal(change.state.endpointUrl, "http://10.0.0.7:4173");
379 assert.equal(change.state.advertisedHost, "100.90.1.2");
380 assert.equal(change.state.endpointUrl, "http://100.90.1.2:4173");
355381
356382 const endpoints = yield* serverExposure.getAdvertisedEndpoints;
357383 assert.deepEqual(
358384 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/"],
360386 );
361387 }),
362388 {
363 T3CODE_DESKTOP_LAN_HOST: "10.0.0.7",
389 T3CODE_DESKTOP_LAN_HOST: "100.90.1.2",
364390 T3CODE_DESKTOP_HTTPS_ENDPOINTS: "https://public.example.test",
365391 },
366392 ),
apps/desktop/src/backend/DesktopServerExposure.ts+12-3
......@@ -9,7 +9,7 @@ import {
99 type DesktopServerExposureMode,
1010 type DesktopServerExposureState,
1111} from "@t3tools/contracts";
12import { readTailscaleStatus } from "@t3tools/tailscale";
12import { isTailscaleIpv4Address, readTailscaleStatus } from "@t3tools/tailscale";
1313import * as Context from "effect/Context";
1414import * as Duration from "effect/Duration";
1515import * as Effect from "effect/Effect";
......@@ -65,7 +65,9 @@ const normalizeOptionalHost = (value: string | undefined): string | undefined =>
6565};
6666
6767const 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
7072const isHttpsEndpointUrl = (value: string): boolean => {
7173 try {
......@@ -376,7 +378,14 @@ function resolveRuntimeState(input: {
376378 ...(advertisedHostOverride ? { advertisedHostOverride } : {}),
377379 });
378380 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 );
380389 const exposure = unavailable
381390 ? resolveDesktopServerExposure({
382391 mode: "local-only",