Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions apps/desktop/src/backend/DesktopNetworkInterfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,13 @@ export type NetworkInterfaces = Readonly<
Record<string, readonly DesktopNetworkInterfaceInfo[] | undefined>
>;

// Some Node builds report the address family as the number 4/6 instead of
// "IPv4"/"IPv6" (see DesktopBackendConfiguration.isLocalHostIpv4).
export const isIpv4Family = (family: string | number): boolean => {
const normalized = String(family);
return normalized === "IPv4" || normalized === "4";
};

export class DesktopNetworkInterfacesReadError extends Schema.TaggedErrorClass<DesktopNetworkInterfacesReadError>()(
"DesktopNetworkInterfacesReadError",
{
Expand Down
159 changes: 159 additions & 0 deletions apps/desktop/src/backend/DesktopServerExposure.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,16 @@ const tailnetNetworkInterfaces: DesktopNetworkInterfaces.NetworkInterfaces = {
],
};

const multiNicNetworkInterfaces: DesktopNetworkInterfaces.NetworkInterfaces = {
wg0: [{ address: "10.8.0.5", family: "IPv4", internal: false }],
en0: [
{ address: "192.168.1.20", family: "IPv4", internal: false },
{ address: "fe80::1", family: "IPv6", internal: false },
],
en1: [{ address: "169.254.10.10", family: "IPv4", internal: false }],
lo0: [{ address: "127.0.0.1", family: "IPv4", internal: true }],
};

function mockSpawnerLayer(statusJson = "{}") {
return Layer.succeed(
ChildProcessSpawner.ChildProcessSpawner,
Expand Down Expand Up @@ -468,4 +478,153 @@ describe("DesktopServerExposure", () => {
},
),
);

it.effect("advertises one LAN endpoint per usable interface", () =>
withHarness(
multiNicNetworkInterfaces,
Effect.gen(function* () {
const serverExposure = yield* DesktopServerExposure.DesktopServerExposure;
yield* serverExposure.configureFromSettings({ port: 4173 });

const change = yield* serverExposure.setMode("network-accessible");
assert.equal(change.state.advertisedHost, "10.8.0.5");
assert.equal(change.state.endpointUrl, "http://10.8.0.5:4173");

const endpoints = yield* serverExposure.getAdvertisedEndpoints;
assert.deepEqual(
endpoints.map((endpoint) => endpoint.httpBaseUrl),
["http://127.0.0.1:4173/", "http://10.8.0.5:4173/", "http://192.168.1.20:4173/"],
);

const wgEndpoint = endpoints.find(
(endpoint) => endpoint.httpBaseUrl === "http://10.8.0.5:4173/",
);
const enEndpoint = endpoints.find(
(endpoint) => endpoint.httpBaseUrl === "http://192.168.1.20:4173/",
);
assert.equal(wgEndpoint?.label, "Local network (wg0)");
assert.equal(enEndpoint?.label, "Local network (en0)");
assert.equal(wgEndpoint?.isDefault, true);
assert.isTrue(enEndpoint !== undefined && !("isDefault" in enEndpoint));
}),
),
);

it.effect("accepts numeric IPv4 family values", () =>
withHarness(
{ eth0: [{ address: "192.168.7.7", family: 4, internal: false }] },
Effect.gen(function* () {
const serverExposure = yield* DesktopServerExposure.DesktopServerExposure;
yield* serverExposure.configureFromSettings({ port: 4173 });

const change = yield* serverExposure.setMode("network-accessible");
assert.equal(change.state.advertisedHost, "192.168.7.7");

const endpoints = yield* serverExposure.getAdvertisedEndpoints;
assert.deepEqual(
endpoints.map((endpoint) => endpoint.httpBaseUrl),
["http://127.0.0.1:4173/", "http://192.168.7.7:4173/"],
);
}),
),
);

it.effect("stays network-accessible on tailnet-only machines without duplicate endpoints", () =>
withHarness(
tailnetNetworkInterfaces,
Effect.gen(function* () {
const serverExposure = yield* DesktopServerExposure.DesktopServerExposure;
yield* serverExposure.configureFromSettings({ port: 4173 });

const change = yield* serverExposure.setMode("network-accessible");
assert.equal(change.state.advertisedHost, "100.90.1.2");

const endpoints = yield* serverExposure.getAdvertisedEndpoints;
const matches = endpoints.filter(
(endpoint) => endpoint.httpBaseUrl === "http://100.90.1.2:4173/",
);
assert.equal(matches.length, 1);
assert.isTrue(matches[0]!.id.startsWith("tailscale-ip:"));
// The dropped LAN duplicate carried the default marker; it must move
// to the surviving Tailscale entry instead of disappearing.
assert.equal(matches[0]!.isDefault, true);
}),
),
);

it.effect("dedupes tailnet addresses reported with numeric family values", () =>
withHarness(
{ tailscale0: [{ address: "100.90.1.2", family: 4, internal: false }] },
Effect.gen(function* () {
const serverExposure = yield* DesktopServerExposure.DesktopServerExposure;
yield* serverExposure.configureFromSettings({ port: 4173 });
yield* serverExposure.setMode("network-accessible");

const endpoints = yield* serverExposure.getAdvertisedEndpoints;
const matches = endpoints.filter(
(endpoint) => endpoint.httpBaseUrl === "http://100.90.1.2:4173/",
);
assert.equal(matches.length, 1);
assert.isTrue(matches[0]!.id.startsWith("tailscale-ip:"));
assert.equal(matches[0]!.isDefault, true);
}),
),
);

it.effect("dedupes an address reported by multiple interfaces", () =>
withHarness(
{
en0: [{ address: "192.168.1.20", family: "IPv4", internal: false }],
en1: [{ address: "192.168.1.20", family: "IPv4", internal: false }],
},
Effect.gen(function* () {
const serverExposure = yield* DesktopServerExposure.DesktopServerExposure;
yield* serverExposure.configureFromSettings({ port: 4173 });
yield* serverExposure.setMode("network-accessible");

const endpoints = yield* serverExposure.getAdvertisedEndpoints;
assert.deepEqual(
endpoints.map((endpoint) => endpoint.httpBaseUrl),
["http://127.0.0.1:4173/", "http://192.168.1.20:4173/"],
);
assert.equal(endpoints[1]!.label, "Local network");
}),
),
);

it.effect("reflects interface changes without reconfiguring", () => {
const mutableInterfaces: {
[name: string]: readonly DesktopNetworkInterfaces.DesktopNetworkInterfaceInfo[];
} = {
en0: [{ address: "192.168.1.20", family: "IPv4", internal: false }],
};
return withHarness(
mutableInterfaces,
Effect.gen(function* () {
const serverExposure = yield* DesktopServerExposure.DesktopServerExposure;
yield* serverExposure.configureFromSettings({ port: 4173 });
yield* serverExposure.setMode("network-accessible");

const initial = yield* serverExposure.getAdvertisedEndpoints;
assert.deepEqual(
initial.map((endpoint) => endpoint.httpBaseUrl),
["http://127.0.0.1:4173/", "http://192.168.1.20:4173/"],
);

mutableInterfaces["wg0"] = [{ address: "10.8.0.5", family: "IPv4", internal: false }];
const afterConnect = yield* serverExposure.getAdvertisedEndpoints;
assert.deepEqual(
afterConnect.map((endpoint) => endpoint.httpBaseUrl),
["http://127.0.0.1:4173/", "http://192.168.1.20:4173/", "http://10.8.0.5:4173/"],
);

delete mutableInterfaces["wg0"];
const afterDisconnect = yield* serverExposure.getAdvertisedEndpoints;
assert.deepEqual(
afterDisconnect.map((endpoint) => endpoint.httpBaseUrl),
["http://127.0.0.1:4173/", "http://192.168.1.20:4173/"],
);
}),
);
});
});
90 changes: 74 additions & 16 deletions apps/desktop/src/backend/DesktopServerExposure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -42,6 +42,7 @@ interface ResolvedDesktopServerExposure {
interface DesktopAdvertisedEndpointInput {
readonly port: number;
readonly exposure: ResolvedDesktopServerExposure;
readonly advertisedLanHosts: readonly LanAdvertisedHost[];
readonly customHttpsEndpointUrls?: readonly string[];
}

Expand All @@ -67,6 +68,11 @@ const normalizeOptionalHost = (value: string | undefined): string | undefined =>
const isUsableLanIpv4Address = (address: string): boolean =>
!address.startsWith("127.") && !address.startsWith("169.254.");

interface LanAdvertisedHost {
readonly address: string;
readonly interfaceName: string | null;
}

const isHttpsEndpointUrl = (value: string): boolean => {
try {
return new URL(value).protocol === "https:";
Expand All @@ -75,27 +81,39 @@ const isHttpsEndpointUrl = (value: string): boolean => {
}
};

const resolveLanAdvertisedHost = (
const resolveLanAdvertisedHosts = (
networkInterfaces: DesktopNetworkInterfaces.NetworkInterfaces,
explicitHost: string | undefined,
): string | null => {
): readonly LanAdvertisedHost[] => {
const normalizedExplicitHost = normalizeOptionalHost(explicitHost);
if (normalizedExplicitHost) {
return normalizedExplicitHost;
return [{ address: normalizedExplicitHost, interfaceName: null }];
}

for (const interfaceAddresses of Object.values(networkInterfaces)) {
const seenAddresses = new Set<string>();
const lanHosts: LanAdvertisedHost[] = [];
const tailnetHosts: LanAdvertisedHost[] = [];

for (const [interfaceName, interfaceAddresses] of Object.entries(networkInterfaces)) {
if (!interfaceAddresses) continue;

for (const address of interfaceAddresses) {
if (address.internal) continue;
if (address.family !== "IPv4") continue;
if (!DesktopNetworkInterfaces.isIpv4Family(address.family)) continue;
if (!isUsableLanIpv4Address(address.address)) continue;
return address.address;
if (seenAddresses.has(address.address)) continue;
seenAddresses.add(address.address);

const host: LanAdvertisedHost = { address: address.address, interfaceName };
if (isTailscaleIpv4Address(address.address)) {
tailnetHosts.push(host);
} else {
lanHosts.push(host);
}
}
}

return null;
return [...lanHosts, ...tailnetHosts];
};

const resolveDesktopServerExposure = (input: {
Expand All @@ -118,10 +136,11 @@ const resolveDesktopServerExposure = (input: {
};
}

const advertisedHost = resolveLanAdvertisedHost(
const advertisedLanHosts = resolveLanAdvertisedHosts(
input.networkInterfaces,
input.advertisedHostOverride,
);
const advertisedHost = advertisedLanHosts[0]?.address ?? null;

return {
mode: input.mode,
Expand Down Expand Up @@ -165,19 +184,30 @@ const resolveDesktopCoreAdvertisedEndpoints = (
}),
];

if (input.exposure.endpointUrl) {
const advertisedLanHosts = input.advertisedLanHosts;
// Tailnet addresses are replaced by the Tailscale provider's own entries
// below, so only the remaining hosts count toward needing disambiguation.
const namedLanHostCount = advertisedLanHosts.filter(
(host) => !isTailscaleIpv4Address(host.address),
).length;
advertisedLanHosts.forEach((host, index) => {
const httpBaseUrl = `http://${host.address}:${input.port}`;
const label =
namedLanHostCount >= 2 && host.interfaceName !== null
? `Local network (${host.interfaceName})`
: "Local network";
endpoints.push(
createDesktopEndpoint({
id: `desktop-lan:${input.exposure.endpointUrl}`,
label: "Local network",
httpBaseUrl: input.exposure.endpointUrl,
id: `desktop-lan:${httpBaseUrl}`,
label,
httpBaseUrl,
reachability: "lan",
status: "available",
isDefault: true,
...(index === 0 ? { isDefault: true } : {}),
description: "Reachable from devices on the same network.",
}),
);
}
});

for (const customEndpointUrl of input.customHttpsEndpointUrls ?? []) {
try {
Expand Down Expand Up @@ -524,9 +554,21 @@ export const make = Effect.gen(function* () {
const getAdvertisedEndpoints = Effect.gen(function* () {
const state = yield* Ref.get(stateRef);
const currentNetworkInterfaces = yield* readNetworkInterfaces;
// Re-resolve LAN hosts on every read so interfaces that appear or vanish
// after startup (VPNs connecting/disconnecting, hot-plugged NICs) stay
// accurate. The backend binds 0.0.0.0 in network-accessible mode, so any
// currently-present address is reachable.
const advertisedLanHosts =
state.mode === "network-accessible"
? resolveLanAdvertisedHosts(
currentNetworkInterfaces,
Option.getOrUndefined(config.desktopLanHostOverride),
)
: [];
const coreEndpoints = resolveDesktopCoreAdvertisedEndpoints({
port: state.port,
exposure: toResolvedExposure(state),
advertisedLanHosts,
customHttpsEndpointUrls: config.desktopHttpsEndpointUrls,
});

Expand All @@ -547,7 +589,23 @@ export const make = Effect.gen(function* () {
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner),
Effect.provideService(HttpClient.HttpClient, httpClient),
);
return [...coreEndpoints, ...tailscaleEndpoints];
// A tailnet address surfaces through the Tailscale provider's richer
// entry; drop the plain LAN duplicate but keep the default marker on
// whichever entry survives.
const tailscaleUrls = new Set(tailscaleEndpoints.map((endpoint) => endpoint.httpBaseUrl));
const isDuplicateLanEndpoint = (endpoint: AdvertisedEndpoint): boolean =>
endpoint.id.startsWith("desktop-lan:") && tailscaleUrls.has(endpoint.httpBaseUrl);
const droppedDefault = coreEndpoints.find(
(endpoint) => isDuplicateLanEndpoint(endpoint) && endpoint.isDefault === true,
);
return [
...coreEndpoints.filter((endpoint) => !isDuplicateLanEndpoint(endpoint)),
...tailscaleEndpoints.map((endpoint) =>
endpoint.httpBaseUrl === droppedDefault?.httpBaseUrl
? { ...endpoint, isDefault: true }
: endpoint,
),
];
}).pipe(Effect.withSpan("desktop.serverExposure.getAdvertisedEndpoints"));

return DesktopServerExposure.of({
Expand Down
4 changes: 2 additions & 2 deletions apps/desktop/src/backend/tailscaleEndpointProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import * as Option from "effect/Option";
import * as HttpClient from "effect/unstable/http/HttpClient";
import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";

import type { NetworkInterfaces } from "./DesktopNetworkInterfaces.ts";
import { isIpv4Family, type NetworkInterfaces } from "./DesktopNetworkInterfaces.ts";

export { isTailscaleIpv4Address, parseTailscaleMagicDnsName } from "@t3tools/tailscale";

Expand All @@ -35,7 +35,7 @@ function resolveTailscaleIpAdvertisedEndpoints(input: {

for (const address of interfaceAddresses) {
if (address.internal) continue;
if (address.family !== "IPv4") continue;
if (!isIpv4Family(address.family)) continue;
if (!isTailscaleIpv4Address(address.address)) continue;
if (seen.has(address.address)) continue;
seen.add(address.address);
Expand Down
Loading
Loading