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
6 changes: 6 additions & 0 deletions apps/desktop/src/backend/DesktopBackendConfiguration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { ChildProcessSpawner } from "effect/unstable/process";
import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts";
import * as DesktopConfig from "../app/DesktopConfig.ts";
import * as DesktopNetworkInterfaces from "./DesktopNetworkInterfaces.ts";
import * as DesktopServerExposure from "./DesktopServerExposure.ts";
import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts";
Expand Down Expand Up @@ -965,6 +966,11 @@ describe("DesktopBackendConfiguration", () => {
Layer.provideMerge(DesktopAppSettings.layerTest()),
Layer.provideMerge(DesktopWslServerTree.layerTest()),
Layer.provideMerge(DesktopWslEnvironment.layer),
Layer.provideMerge(
Layer.succeed(DesktopNetworkInterfaces.DesktopNetworkInterfaces, {
read: Effect.succeed({}),
}),
),
// isAvailable on win32 only touches the filesystem, never the spawner,
// so a die-stub is enough to satisfy the layer's deps.
Layer.provideMerge(
Expand Down
106 changes: 106 additions & 0 deletions apps/desktop/src/wsl/DesktopWslEnvironment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,14 @@ import {
formatMissingToolsReason,
formatNodePtyProbeFailureReason,
formatWslShellTransportFailureReason,
parseDistroIpCandidates,
parseNodePath,
parseNodeVersion,
parseResolvedPath,
parseToolchainReport,
pickDistroIp,
probeWslDistros,
windowsIpv4Interfaces,
} from "./DesktopWslEnvironment.ts";

const encoder = new TextEncoder();
Expand Down Expand Up @@ -88,6 +91,109 @@ describe("probeWslDistros", () => {
});
});

describe("parseDistroIpCandidates", () => {
it("orders the route src ahead of the hostname -I list and dedupes", () => {
const stdout = [
"route:1.1.1.1 via 172.27.0.1 dev eth0 src 172.27.5.44 uid 1000",
"all:172.17.0.1 172.27.5.44",
].join("\n");
expect(parseDistroIpCandidates(stdout)).toEqual(["172.27.5.44", "172.17.0.1"]);
});

it("collects only hostname -I addresses when there is no default route", () => {
expect(parseDistroIpCandidates("route:\nall:172.27.5.44 fe80::1")).toEqual(["172.27.5.44"]);
});

it("skips a route line without a valid IPv4 src token", () => {
const stdout = ["route:1.1.1.1 dev eth0 src fdcc::2 metric 256", "all:172.27.5.44"].join("\n");
expect(parseDistroIpCandidates(stdout)).toEqual(["172.27.5.44"]);
});

it("accepts CRLF output", () => {
expect(parseDistroIpCandidates("route:\r\nall:172.27.5.44\r\n")).toEqual(["172.27.5.44"]);
});

it("returns no candidates when neither probe produced an IPv4 address", () => {
expect(parseDistroIpCandidates("route:\nall:")).toEqual([]);
expect(parseDistroIpCandidates("")).toEqual([]);
});
});

describe("pickDistroIp", () => {
const wslVEthernet = {
name: "vEthernet (WSL (Hyper-V firewall))",
address: "172.27.0.1",
netmask: "255.255.240.0",
};
const wifi = { name: "Wi-Fi", address: "192.168.1.219", netmask: "255.255.255.0" };

it("picks the eth0 address in the WSL vEthernet subnet over Docker bridges (#5211)", () => {
// Docker bridge networks sort first in `hostname -I` but are internal to
// the distro; only eth0 shares a subnet with a Windows interface.
expect(pickDistroIp(["172.17.0.1", "172.19.0.1", "172.27.5.44"], [wslVEthernet, wifi])).toBe(
"172.27.5.44",
);
});

it("skips a VPN tunnel src that owns the Internet route inside the distro", () => {
// A full-tunnel VPN inside WSL makes `ip route get 1.1.1.1` report the
// tunnel address, which Windows cannot reach; eth0 must still win.
expect(pickDistroIp(["10.8.0.5", "172.17.0.1", "172.27.5.44"], [wslVEthernet, wifi])).toBe(
"172.27.5.44",
);
});

it("picks the mirrored-mode address that equals a Windows interface IP", () => {
expect(pickDistroIp(["192.168.1.219"], [wifi])).toBe("192.168.1.219");
});

it("outranks a Windows VPN whose address space overlaps an in-distro tunnel", () => {
// A corporate VPN adapter on Windows can share 10.x/172.x space with an
// in-distro tunnel or Docker bridge; the WSL adapter match must win even
// though the tunnel src is the first candidate.
const corporateVpn = { name: "Ethernet 3", address: "10.8.44.7", netmask: "255.255.0.0" };
expect(
pickDistroIp(["10.8.0.5", "172.17.0.1", "172.27.5.44"], [corporateVpn, wslVEthernet]),
).toBe("172.27.5.44");
});

it("still accepts a subnet match on a custom-named switch when no WSL adapter matches", () => {
const customSwitch = {
name: "vEthernet (Custom)",
address: "192.168.100.1",
netmask: "255.255.255.0",
};
expect(pickDistroIp(["192.168.100.44"], [customSwitch])).toBe("192.168.100.44");
});

it("falls back to the first candidate when nothing is provably reachable", () => {
expect(pickDistroIp(["10.8.0.5", "172.17.0.1"], [wifi])).toBe("10.8.0.5");
expect(pickDistroIp(["172.27.5.44"], [])).toBe("172.27.5.44");
});

it("returns null with no candidates", () => {
expect(pickDistroIp([], [wslVEthernet])).toBeNull();
});
});

describe("windowsIpv4Interfaces", () => {
it("flattens IPv4 entries across adapters, accepting string and numeric family", () => {
expect(
windowsIpv4Interfaces({
"vEthernet (WSL)": [
{ address: "172.27.0.1", family: "IPv4", internal: false, netmask: "255.255.240.0" },
{ address: "fe80::1", family: "IPv6", internal: false, netmask: "ffff:ffff:ffff:ffff::" },
],
"Wi-Fi": [{ address: "192.168.1.219", family: 4, internal: false }],
Disconnected: undefined,
}),
).toEqual([
{ name: "vEthernet (WSL)", address: "172.27.0.1", netmask: "255.255.240.0" },
{ name: "Wi-Fi", address: "192.168.1.219", netmask: undefined },
]);
});
});

describe("formatNodePtyProbeFailureReason", () => {
it("identifies a packaged build that omitted the Linux node-pty prebuild", () => {
const reason = formatNodePtyProbeFailureReason(4);
Expand Down
136 changes: 128 additions & 8 deletions apps/desktop/src/wsl/DesktopWslEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { buildRemoteNodeEnvScript } from "@t3tools/ssh/tunnel";
import { satisfiesSemverRange } from "@t3tools/shared/semver";

import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
import * as DesktopNetworkInterfaces from "../backend/DesktopNetworkInterfaces.ts";
import { parseWslDistroList, type WslDistro } from "./wslPathParsing.ts";

const PROCESS_TERMINATE_GRACE = Duration.seconds(1);
Expand Down Expand Up @@ -686,19 +687,130 @@ const windowsToWslPathImpl = (

const IPV4_PATTERN = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/;

const DISTRO_IP_ROUTE_PREFIX = "route:";
const DISTRO_IP_ADDRESSES_PREFIX = "all:";

// Emits the distro's IPv4 candidates: the `src` of `ip -4 route get 1.1.1.1`
// (a pure routing-table lookup, no packet is sent) followed by everything
// `hostname -I` reports. Neither source alone is trustworthy — `hostname -I`
// lists Docker bridge addresses before eth0 (#5211), and the Internet route's
// src is a tunnel address when a VPN/VRF inside the distro owns that route —
// so pickDistroIp validates the candidates against the Windows-side
// interfaces instead of trusting either ordering.
const DISTRO_IP_SCRIPT = `printf "${DISTRO_IP_ROUTE_PREFIX}%s\\n" "$(ip -4 route get 1.1.1.1 2>/dev/null)"; printf "${DISTRO_IP_ADDRESSES_PREFIX}%s\\n" "$(hostname -I 2>/dev/null)"`;
Comment thread
macroscopeapp[bot] marked this conversation as resolved.

export const parseDistroIpCandidates = (stdout: string): ReadonlyArray<string> => {
const candidates: string[] = [];
const push = (ip: string) => {
if (!candidates.includes(ip)) candidates.push(ip);
};
for (const line of stdout.split(/\r?\n/)) {
const trimmed = line.trim();
if (trimmed.startsWith(DISTRO_IP_ROUTE_PREFIX)) {
const tokens = trimmed.slice(DISTRO_IP_ROUTE_PREFIX.length).trim().split(/\s+/);
const srcIndex = tokens.indexOf("src");
const candidate = srcIndex === -1 ? undefined : tokens[srcIndex + 1];
if (candidate !== undefined && IPV4_PATTERN.test(candidate)) push(candidate);
} else if (trimmed.startsWith(DISTRO_IP_ADDRESSES_PREFIX)) {
for (const part of trimmed.slice(DISTRO_IP_ADDRESSES_PREFIX.length).split(/\s+/)) {
if (IPV4_PATTERN.test(part)) push(part);
}
}
}
return candidates;
};

export interface WindowsIpv4Interface {
readonly name: string;
readonly address: string;
readonly netmask: string | undefined;
}

const ipv4ToInt = (ip: string): number | null => {
const parts = ip.split(".");
if (parts.length !== 4) return null;
let value = 0;
for (const part of parts) {
const octet = Number(part);
if (!Number.isInteger(octet) || octet < 0 || octet > 255) return null;
value = value * 256 + octet;
}
return value;
};

const inSameSubnet = (a: string, b: string, netmask: string): boolean => {
const aInt = ipv4ToInt(a);
const bInt = ipv4ToInt(b);
const maskInt = ipv4ToInt(netmask);
if (aInt === null || bInt === null || maskInt === null) return false;
return (aInt & maskInt) === (bInt & maskInt);
};

const inInterfaceSubnet = (candidate: string, iface: WindowsIpv4Interface): boolean =>
iface.netmask !== undefined && inSameSubnet(candidate, iface.address, iface.netmask);

const isWslAdapterName = (name: string): boolean => name.toLowerCase().includes("wsl");

// Ranked selection, strongest signal first, so a weak match on an early
// candidate can never shadow a strong match on a later one:
// 1. A candidate equal to a Windows interface address is the mirrored-mode
// signature (DesktopBackendConfiguration then swaps the renderer URL to
// loopback).
// 2. A candidate inside the subnet of a WSL-named adapter ("vEthernet (WSL)",
// "vEthernet (WSL (Hyper-V firewall))") is the NAT-mode eth0 address.
// 3. A candidate inside any other Windows interface's subnet covers renamed
// or custom Hyper-V switches — ranked last so a Windows-side VPN whose
// 10.x/172.x space overlaps an in-distro tunnel or Docker bridge cannot
// capture the probe while the real WSL adapter has a match.
// Docker bridges and in-distro VPN tunnels normally match no pass. When
// nothing matches, fall back to the first candidate, preserving the
// pre-validation behavior.
export const pickDistroIp = (
candidates: ReadonlyArray<string>,
windowsInterfaces: ReadonlyArray<WindowsIpv4Interface>,
): string | null => {
const passes: ReadonlyArray<(candidate: string, iface: WindowsIpv4Interface) => boolean> = [
(candidate, iface) => candidate === iface.address,
(candidate, iface) => isWslAdapterName(iface.name) && inInterfaceSubnet(candidate, iface),
(candidate, iface) => inInterfaceSubnet(candidate, iface),
];
for (const pass of passes) {
for (const candidate of candidates) {
if (windowsInterfaces.some((iface) => pass(candidate, iface))) return candidate;
}
}
return candidates[0] ?? null;
};
Comment thread
cursor[bot] marked this conversation as resolved.

export const windowsIpv4Interfaces = (
interfaces: DesktopNetworkInterfaces.NetworkInterfaces,
): ReadonlyArray<WindowsIpv4Interface> => {
const flattened: WindowsIpv4Interface[] = [];
for (const [name, list] of Object.entries(interfaces)) {
if (!list) continue;
for (const entry of list) {
// Same family normalization as isLocalHostIpv4 in
// DesktopBackendConfiguration: Electron's Node reports the string
// "IPv4", some Node builds report the numeric 4.
const family = String(entry.family);
if (family === "IPv4" || family === "4") {
flattened.push({ name, address: entry.address, netmask: entry.netmask });
}
}
}
return flattened;
};

const getDistroIpImpl = (
distro: string | null,
readNetworkInterfaces: Effect.Effect<DesktopNetworkInterfaces.NetworkInterfaces>,
): Effect.Effect<Option.Option<string>, never, ChildProcessSpawner.ChildProcessSpawner> =>
Effect.scoped(
Effect.gen(function* () {
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
// `hostname -I` prints a space-separated list of all non-loopback
// IPs the distro has bound. The first entry on the WSL2 default
// network is always the eth0 vEthernet address Windows can reach
// directly (no wslhost forwarding required).
const command = ChildProcess.make(
"wsl.exe",
[...buildDistroArgs(distro), "--", "sh", "-c", "hostname -I"],
[...buildDistroArgs(distro), "--", "sh", "-c", DISTRO_IP_SCRIPT],
{
stdin: "ignore",
stdout: "pipe",
Expand All @@ -712,8 +824,13 @@ const getDistroIpImpl = (
const exitCode = yield* handle.exitCode;
if ((exitCode as unknown as number) !== 0) return Option.none<string>();
const raw = decodeUtf8(concatChunks(stdoutBytes)).trim();
const candidate = raw.split(/\s+/).find((part) => IPV4_PATTERN.test(part));
return candidate ? Option.some(candidate) : Option.none<string>();
const candidates = parseDistroIpCandidates(raw);
const interfaces = yield* readNetworkInterfaces;
const chosen = pickDistroIp(candidates, windowsIpv4Interfaces(interfaces));
yield* Effect.log(
`[wsl] distro IP probe chose ${chosen ?? "none"} from candidates [${candidates.join(", ")}]`,
);
return chosen === null ? Option.none<string>() : Option.some(chosen);
}),
).pipe(
Effect.timeoutOption(USER_HOME_TIMEOUT),
Expand Down Expand Up @@ -818,6 +935,7 @@ export const layer = Layer.effect(
const environment = yield* DesktopEnvironment.DesktopEnvironment;
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const fileSystem = yield* FileSystem.FileSystem;
const networkInterfaces = yield* DesktopNetworkInterfaces.DesktopNetworkInterfaces;
const windir = process.env.WINDIR ?? "C:\\Windows";

const provideSpawner = <A, E>(
Expand Down Expand Up @@ -864,7 +982,9 @@ export const layer = Layer.effect(
}).pipe(Effect.withSpan("desktop.wsl.getUserHome"));

const getDistroIp = (distro: string | null) =>
provideSpawner(getDistroIpImpl(distro)).pipe(Effect.withSpan("desktop.wsl.getDistroIp"));
provideSpawner(getDistroIpImpl(distro, networkInterfaces.read)).pipe(
Effect.withSpan("desktop.wsl.getDistroIp"),
);

const probeDistros = provideSpawner(probeWslDistros).pipe(
Effect.withSpan("desktop.wsl.probeDistros"),
Expand Down
Loading