diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index 2bbde73abaa2..024ce758a4e6 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -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"; @@ -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( diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts index 895d246e3689..e5203ae6aa98 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts @@ -15,11 +15,14 @@ import { formatMissingToolsReason, formatNodePtyProbeFailureReason, formatWslShellTransportFailureReason, + parseDistroIpCandidates, parseNodePath, parseNodeVersion, parseResolvedPath, parseToolchainReport, + pickDistroIp, probeWslDistros, + windowsIpv4Interfaces, } from "./DesktopWslEnvironment.ts"; const encoder = new TextEncoder(); @@ -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); diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.ts index 164117727eaa..14dc33e63966 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.ts @@ -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); @@ -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)"`; + +export const parseDistroIpCandidates = (stdout: string): ReadonlyArray => { + 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, + windowsInterfaces: ReadonlyArray, +): 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; +}; + +export const windowsIpv4Interfaces = ( + interfaces: DesktopNetworkInterfaces.NetworkInterfaces, +): ReadonlyArray => { + 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, ): Effect.Effect, 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", @@ -712,8 +824,13 @@ const getDistroIpImpl = ( const exitCode = yield* handle.exitCode; if ((exitCode as unknown as number) !== 0) return Option.none(); const raw = decodeUtf8(concatChunks(stdoutBytes)).trim(); - const candidate = raw.split(/\s+/).find((part) => IPV4_PATTERN.test(part)); - return candidate ? Option.some(candidate) : Option.none(); + 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() : Option.some(chosen); }), ).pipe( Effect.timeoutOption(USER_HOME_TIMEOUT), @@ -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 = ( @@ -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"),