diff --git a/src/main/ipc/mobile.test.ts b/src/main/ipc/mobile.test.ts index 51ce4a6c9f1..e8cc2edb319 100644 --- a/src/main/ipc/mobile.test.ts +++ b/src/main/ipc/mobile.test.ts @@ -1,7 +1,8 @@ import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest' import type * as NodeOs from 'node:os' -const { handleMock, networkInterfacesMock } = vi.hoisted(() => ({ +const { defaultRouteInterfaceNamesMock, handleMock, networkInterfacesMock } = vi.hoisted(() => ({ + defaultRouteInterfaceNamesMock: vi.fn(), handleMock: vi.fn(), networkInterfacesMock: vi.fn() })) @@ -25,6 +26,10 @@ vi.mock('os', async (importOriginal) => ({ networkInterfaces: networkInterfacesMock })) +vi.mock('../runtime/windows-default-route-interfaces', () => ({ + getWindowsDefaultRouteInterfaceNames: defaultRouteInterfaceNamesMock +})) + import { mkdtempSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -42,12 +47,13 @@ describe('registerMobileHandlers', () => { handleMock.mockReset() networkInterfacesMock.mockReset() networkInterfacesMock.mockReturnValue({}) + defaultRouteInterfaceNamesMock.mockReset().mockResolvedValue(new Set()) handleMock.mockImplementation((channel: string, handler: (...args: unknown[]) => unknown) => { handlers.set(channel, handler) }) }) - it('re-reads system network interfaces on each request and prefers tailnet addresses', () => { + it('re-reads system network interfaces on each request and prefers tailnet addresses', async () => { networkInterfacesMock .mockReturnValueOnce({ en0: [{ family: 'IPv4', internal: false, address: '192.168.1.24' }] @@ -59,15 +65,16 @@ describe('registerMobileHandlers', () => { registerMobileHandlers({} as never) - expect(handlers.get('mobile:listNetworkInterfaces')?.()).toEqual({ + await expect(handlers.get('mobile:listNetworkInterfaces')?.()).resolves.toEqual({ interfaces: [{ name: 'en0', address: '192.168.1.24' }] }) - expect(handlers.get('mobile:listNetworkInterfaces')?.()).toEqual({ + await expect(handlers.get('mobile:listNetworkInterfaces')?.()).resolves.toEqual({ interfaces: [ { name: 'tailscale0', address: '100.64.1.20' }, { name: 'en0', address: '192.168.1.24' } ] }) + expect(defaultRouteInterfaceNamesMock).not.toHaveBeenCalled() }) it('excludes proxy fake-ip addresses so pairing defaults to LAN (#10404)', async () => { @@ -92,7 +99,7 @@ describe('registerMobileHandlers', () => { registerMobileHandlers({ createMobilePairingOffer } as never) - expect(handlers.get('mobile:listNetworkInterfaces')?.()).toEqual({ + await expect(handlers.get('mobile:listNetworkInterfaces')?.()).resolves.toEqual({ interfaces: [ { name: 'en0', address: '192.168.50.238' }, { name: 'en0', address: '198.17.255.254' }, @@ -106,7 +113,7 @@ describe('registerMobileHandlers', () => { ) }) - it('includes IPv6 addresses (ranked after IPv4) and excludes link-local IPv6', () => { + it('includes IPv6 addresses (ranked after IPv4) and excludes link-local IPv6', async () => { networkInterfacesMock.mockReturnValue({ en0: [ { family: 'IPv4', internal: false, address: '192.168.1.24' }, @@ -118,7 +125,7 @@ describe('registerMobileHandlers', () => { registerMobileHandlers({} as never) - expect(handlers.get('mobile:listNetworkInterfaces')?.()).toEqual({ + await expect(handlers.get('mobile:listNetworkInterfaces')?.()).resolves.toEqual({ interfaces: [ { name: 'en0', address: '192.168.1.24' }, { name: 'en0', address: '2605:340:cd51:2a01:0:2b13:f279:c096' } @@ -126,7 +133,7 @@ describe('registerMobileHandlers', () => { }) }) - it('ranks container and VM bridges below every reachable address', () => { + it('ranks container and VM bridges below every reachable address', async () => { // Why: a phone can never reach docker0, so advertising it makes the direct // path lose the pairing race and silently relays every session. networkInterfacesMock.mockReturnValue({ @@ -142,7 +149,7 @@ describe('registerMobileHandlers', () => { registerMobileHandlers({} as never) // Real IPv6 outranks a bridge IPv4: the phone can reach one, never the other. - expect(handlers.get('mobile:listNetworkInterfaces')?.()).toEqual({ + await expect(handlers.get('mobile:listNetworkInterfaces')?.()).resolves.toEqual({ interfaces: [ { name: 'en0', address: '192.168.1.24' }, { name: 'en0', address: '2605:340:cd51:2a01:0:2b13:f279:c096' }, @@ -153,7 +160,59 @@ describe('registerMobileHandlers', () => { }) }) - it('keeps a real LAN address that merely overlaps a container subnet', () => { + it('auto-advertises a route-backed Hyper-V external-switch management address', async () => { + networkInterfacesMock.mockReturnValue({ + 'vEthernet (Production LAN)': [{ family: 'IPv4', internal: false, address: '192.168.50.24' }], + 'vEthernet (Default Switch)': [{ family: 'IPv4', internal: false, address: '172.28.80.1' }], + 'vEthernet (WSL (Hyper-V firewall))': [ + { family: 'IPv4', internal: false, address: '172.20.96.1' } + ], + Ethernet: [{ family: 'IPv4', internal: false, address: '192.168.50.25' }] + }) + defaultRouteInterfaceNamesMock.mockResolvedValue( + new Set([ + 'vEthernet (Production LAN)', + 'vEthernet (Default Switch)', + 'vEthernet (WSL (Hyper-V firewall))' + ]) + ) + const createMobilePairingOffer = vi.fn().mockResolvedValue({ + available: true, + pairingUrl: 'orca://pair#external-switch', + endpoint: 'ws://192.168.50.24:6768', + deviceId: 'mobile-external-switch', + connectionMode: 'automatic' + }) + + registerMobileHandlers({ createMobilePairingOffer } as never) + + await expect(handlers.get('mobile:listNetworkInterfaces')?.()).resolves.toEqual({ + interfaces: [ + { + name: 'vEthernet (Production LAN)', + address: '192.168.50.24', + hasDefaultRoute: true + }, + { name: 'Ethernet', address: '192.168.50.25' }, + { + name: 'vEthernet (Default Switch)', + address: '172.28.80.1', + hasDefaultRoute: true + }, + { + name: 'vEthernet (WSL (Hyper-V firewall))', + address: '172.20.96.1', + hasDefaultRoute: true + } + ] + }) + await handlers.get('mobile:getPairingQR')?.(null, {}) + expect(createMobilePairingOffer).toHaveBeenCalledWith( + expect.objectContaining({ address: '192.168.50.24' }) + ) + }) + + it('keeps a real LAN address that merely overlaps a container subnet', async () => { // Why: Docker's 172.16/12 pool overlaps genuine corporate LANs, so the // bridge check keys on interface name — a subnet test would demote this. networkInterfacesMock.mockReturnValue({ @@ -163,7 +222,7 @@ describe('registerMobileHandlers', () => { registerMobileHandlers({} as never) - expect(handlers.get('mobile:listNetworkInterfaces')?.()).toEqual({ + await expect(handlers.get('mobile:listNetworkInterfaces')?.()).resolves.toEqual({ interfaces: [ { name: 'eth0', address: '172.17.4.9' }, { name: 'docker0', address: '172.17.0.1' } @@ -199,7 +258,7 @@ describe('registerMobileHandlers', () => { expect.objectContaining({ address: null }) ) // The bridges stay pickable, just never automatically. - expect(handlers.get('mobile:listNetworkInterfaces')?.()).toEqual({ + await expect(handlers.get('mobile:listNetworkInterfaces')?.()).resolves.toEqual({ interfaces: [ { name: 'docker0', address: '172.17.0.1' }, { name: 'vEthernet (WSL)', address: '172.28.80.1' } @@ -252,7 +311,7 @@ describe('registerMobileHandlers', () => { ) }) - it('returns an IPv6 interface on an IPv6-only host (regression: was empty, breaking mobile pairing)', () => { + it('returns an IPv6 interface on an IPv6-only host (regression: was empty, breaking mobile pairing)', async () => { networkInterfacesMock.mockReturnValue({ eth0: [ { family: 'IPv6', internal: false, address: '2605:340:cd51:2a01:0:2b13:f279:c096' }, @@ -262,7 +321,7 @@ describe('registerMobileHandlers', () => { registerMobileHandlers({} as never) - expect(handlers.get('mobile:listNetworkInterfaces')?.()).toEqual({ + await expect(handlers.get('mobile:listNetworkInterfaces')?.()).resolves.toEqual({ interfaces: [{ name: 'eth0', address: '2605:340:cd51:2a01:0:2b13:f279:c096' }] }) }) diff --git a/src/main/ipc/mobile.ts b/src/main/ipc/mobile.ts index 637d579cae8..d76cb880342 100644 --- a/src/main/ipc/mobile.ts +++ b/src/main/ipc/mobile.ts @@ -1,20 +1,21 @@ import { app, ipcMain, shell, type IpcMainInvokeEvent } from 'electron' -import { networkInterfaces } from 'node:os' import type { RuntimeAccessGrant } from '../../shared/runtime-access-grants' import type { MobilePairingConnectionMode } from '../../shared/mobile-pairing-connection-mode' -import { - isVirtualBridgeInterface, - selectAutoAdvertisedPairingAddress -} from '../../shared/pairing-address-auto-selection' import { classifyRemotePairingHostname } from '../../shared/remote-pairing-address' import type { RuntimePairingReach } from '../../shared/runtime-pairing-reach' -import { isTailnetIPv4Address } from '../../shared/tailnet-address' import type { DeviceEntry } from '../runtime/device-registry' import { NETWORK_EXPOSURE_FAILED_GUIDANCE } from '../runtime/network-exposure-guidance' +import { + getDefaultPairingAddress, + getPairingNetworkInterfaces, + type DefaultRouteInterfaceLookup, + type NetworkInterface +} from '../runtime/pairing-network-interfaces' import { resolveAdvertisedPairingHostname } from '../runtime/pairing-endpoint' import type { OrcaRuntimeRpcServer } from '../runtime/runtime-rpc' import type { RelayBrokerStatus } from '../runtime/relay/relay-session-broker' import { encodeMobilePairingQr, type MobilePairingQrResult } from '../runtime/mobile-pairing-qr' +import { getWindowsDefaultRouteInterfaceNames } from '../runtime/windows-default-route-interfaces' import { getWebSocketPort, inspectWindowsMobileFirewall, @@ -22,72 +23,6 @@ import { type WindowsMobileFirewallEnvironment } from '../runtime/windows-mobile-firewall' -export type NetworkInterface = { - name: string - address: string -} - -// Why: link-local IPv6 addresses (fe80::/10) require a scope/zone id to be -// connectable and never work as a QR-advertised pairing host, so they are -// excluded from the pickable list. The regex covers the full /10 range -// (fe80: through febf:), not just the fe80: prefix the OS usually assigns. -function isUsableIPv6Address(address: string): boolean { - return !/^fe[89ab][0-9a-f]:/i.test(address) -} - -function isProxyFakeIpIPv4Address(address: string): boolean { - return /^198\.(?:18|19)\./.test(address) -} - -// Why: the WebSocket transport advertises 0.0.0.0 as its endpoint, which isn't -// connectable from a mobile device. We enumerate all non-internal IPv4 and -// (non-link-local) IPv6 addresses so the user can choose which one to advertise -// in the QR code (e.g. LAN vs Tailscale). IPv6 must be included so pairing works -// on IPv6-only hosts (e.g. a headless `orca serve` reachable only over IPv6), -// where an IPv4-only scan returns nothing and the UI reports "no interfaces". -function getNetworkInterfaces(): NetworkInterface[] { - const result: NetworkInterface[] = [] - const interfaces = networkInterfaces() - for (const [name, addrs] of Object.entries(interfaces)) { - if (!addrs) { - continue - } - for (const addr of addrs) { - if (addr.internal) { - continue - } - if (addr.family === 'IPv4') { - // 198.18.0.0/15 proxy fake IPs are only routable inside the desktop proxy. - if (isProxyFakeIpIPv4Address(addr.address)) { - continue - } - result.push({ name, address: addr.address }) - } else if (addr.family === 'IPv6' && isUsableIPv6Address(addr.address)) { - result.push({ name, address: addr.address }) - } - } - } - // Why: prefer tailnet IPv4 first (most portable across networks), then other - // IPv4, then IPv6 as a fallback for IPv6-only environments. Virtual bridges - // sort below all of them and stay in the list so they remain explicitly - // pickable; the automatic default skips them entirely. - return result.sort((a, b) => rankInterface(a) - rankInterface(b)) -} - -function rankInterface({ name, address }: NetworkInterface): number { - if (isTailnetIPv4Address(address)) { - return 0 - } - const bridgePenalty = isVirtualBridgeInterface(name) ? 2 : 0 - return (address.includes(':') ? 2 : 1) + bridgePenalty -} - -// Why: null when every enumerated interface is a host-local bridge, so a docker0-only host -// advertises no direct address instead of one the phone provably cannot reach. -function getDefaultPairingAddress(): string | null { - return selectAutoAdvertisedPairingAddress(getNetworkInterfaces()) ?? null -} - // Why: only an explicit "This computer only" pick skips the one-way widen, and only when the address it // advertises really is loopback — a mismatch (a LAN address under a this-computer reach) would otherwise // mint a link with no listener behind it. Every other reach, including a loopback-looking Custom address @@ -119,6 +54,7 @@ export type MobileHandlerDependencies = { getRelayStatus?: () => RelayBrokerStatus consumePendingUnpairedDeviceAuthFailure?: (webContentsId: number) => boolean encodePairingQr?: (pairingUrl: string) => Promise + getDefaultRouteInterfaceNames?: DefaultRouteInterfaceLookup } export function registerMobileHandlers( @@ -131,9 +67,14 @@ export function registerMobileHandlers( executablePath: process.execPath, systemRoot: process.env.SystemRoot } - ipcMain.handle('mobile:listNetworkInterfaces', (): { interfaces: NetworkInterface[] } => ({ - interfaces: getNetworkInterfaces() - })) + const getDefaultRouteInterfaceNames = + dependencies.getDefaultRouteInterfaceNames ?? getWindowsDefaultRouteInterfaceNames + ipcMain.handle( + 'mobile:listNetworkInterfaces', + async (): Promise<{ interfaces: NetworkInterface[] }> => ({ + interfaces: await getPairingNetworkInterfaces(getDefaultRouteInterfaceNames) + }) + ) ipcMain.handle( 'mobile:getPairingQR', @@ -148,7 +89,7 @@ export function registerMobileHandlers( // Why: allow the caller to specify which network interface address to // embed in the QR code. This supports overlay networks (Tailscale, // ZeroTier) where the default LAN IP isn't reachable from the phone. - const ip = args?.address ?? getDefaultPairingAddress() + const ip = args?.address ?? (await getDefaultPairingAddress(getDefaultRouteInterfaceNames)) // Why: the local address is optional under Relay — the QR carries the relay invite, so a host // with nothing auto-advertisable (only container bridges, or no interface at all) still pairs. // The offer's endpoint then falls back to loopback, which is the phone's own device: the direct @@ -206,7 +147,7 @@ export function registerMobileHandlers( ipcMain.handle( 'mobile:getRuntimePairingUrl', async (_event, args?: { address?: string; rotate?: boolean; reach?: RuntimePairingReach }) => { - const ip = args?.address ?? getDefaultPairingAddress() + const ip = args?.address ?? (await getDefaultPairingAddress(getDefaultRouteInterfaceNames)) if (!ip) { return { available: false as const } } diff --git a/src/main/runtime/pairing-network-interfaces.ts b/src/main/runtime/pairing-network-interfaces.ts new file mode 100644 index 00000000000..8c0448ec7e3 --- /dev/null +++ b/src/main/runtime/pairing-network-interfaces.ts @@ -0,0 +1,80 @@ +import { networkInterfaces } from 'node:os' +import { + isVirtualBridgeInterface, + selectAutoAdvertisedPairingAddress +} from '../../shared/pairing-address-auto-selection' +import { isTailnetIPv4Address } from '../../shared/tailnet-address' +import { getWindowsDefaultRouteInterfaceNames } from './windows-default-route-interfaces' + +export type NetworkInterface = { + name: string + address: string + hasDefaultRoute?: boolean +} + +export type DefaultRouteInterfaceLookup = () => Promise | null> + +function isUsableIPv6Address(address: string): boolean { + // Why: link-local IPv6 needs an unadvertised scope id, so a QR hostname cannot connect to it. + return !/^fe[89ab][0-9a-f]:/i.test(address) +} + +function isProxyFakeIpIPv4Address(address: string): boolean { + return /^198\.(?:18|19)\./.test(address) +} + +export async function getPairingNetworkInterfaces( + getDefaultRouteInterfaceNames: DefaultRouteInterfaceLookup = getWindowsDefaultRouteInterfaceNames +): Promise { + const result: NetworkInterface[] = [] + const interfaces = networkInterfaces() + const hasHyperVInterface = Object.keys(interfaces).some((name) => /^vEthernet /i.test(name)) + // Why: an unavailable route query is unknown, so no vEthernet adapter receives reachability proof. + const defaultRouteInterfaceNames = hasHyperVInterface + ? await getDefaultRouteInterfaceNames() + : new Set() + const normalizedDefaultRouteInterfaceNames = + defaultRouteInterfaceNames === null + ? null + : new Set([...defaultRouteInterfaceNames].map((name) => name.toLowerCase())) + + for (const [name, addrs] of Object.entries(interfaces)) { + for (const addr of addrs ?? []) { + if (addr.internal || (addr.family === 'IPv4' && isProxyFakeIpIPv4Address(addr.address))) { + continue + } + if ( + addr.family !== 'IPv4' && + !(addr.family === 'IPv6' && isUsableIPv6Address(addr.address)) + ) { + continue + } + result.push({ + name, + address: addr.address, + ...(normalizedDefaultRouteInterfaceNames?.has(name.toLowerCase()) + ? { hasDefaultRoute: true } + : {}) + }) + } + } + return result.sort((a, b) => rankInterface(a) - rankInterface(b)) +} + +function rankInterface({ name, address, hasDefaultRoute }: NetworkInterface): number { + if (isTailnetIPv4Address(address)) { + return 0 + } + const bridgePenalty = isVirtualBridgeInterface(name, hasDefaultRoute) ? 2 : 0 + return (address.includes(':') ? 2 : 1) + bridgePenalty +} + +export async function getDefaultPairingAddress( + getDefaultRouteInterfaceNames: DefaultRouteInterfaceLookup = getWindowsDefaultRouteInterfaceNames +): Promise { + return ( + selectAutoAdvertisedPairingAddress( + await getPairingNetworkInterfaces(getDefaultRouteInterfaceNames) + ) ?? null + ) +} diff --git a/src/main/runtime/windows-default-route-interfaces.test.ts b/src/main/runtime/windows-default-route-interfaces.test.ts new file mode 100644 index 00000000000..23d1a10a38e --- /dev/null +++ b/src/main/runtime/windows-default-route-interfaces.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it, vi } from 'vitest' +import { + getWindowsDefaultRouteInterfaceNames, + type WindowsDefaultRouteEnvironment +} from './windows-default-route-interfaces' + +function environment( + runPowerShell: WindowsDefaultRouteEnvironment['runPowerShell'], + platform: NodeJS.Platform = 'win32' +): WindowsDefaultRouteEnvironment { + return { platform, systemRoot: 'C:\\Windows', runPowerShell } +} + +describe('getWindowsDefaultRouteInterfaceNames', () => { + it('returns active IPv4 and IPv6 default-route interface aliases', async () => { + const runPowerShell = vi.fn().mockResolvedValue('["vEthernet (Production LAN)","Ethernet"]') + + await expect(getWindowsDefaultRouteInterfaceNames(environment(runPowerShell))).resolves.toEqual( + new Set(['vEthernet (Production LAN)', 'Ethernet']) + ) + + const script = runPowerShell.mock.calls[0]![0] as string + expect(runPowerShell.mock.calls[0]![1]).toBe(3_000) + expect(script).toContain("$ProgressPreference = 'SilentlyContinue'") + expect(script).toContain("$_.State -eq 'Alive'") + expect(script).toContain("$_.DestinationPrefix -eq '0.0.0.0/0'") + expect(script).toContain("$_.DestinationPrefix -eq '::/0'") + }) + + it('returns unknown when route inspection fails or emits malformed data', async () => { + await expect( + getWindowsDefaultRouteInterfaceNames( + environment(vi.fn().mockRejectedValue(new Error('managed host'))) + ) + ).resolves.toBeNull() + await expect( + getWindowsDefaultRouteInterfaceNames(environment(vi.fn().mockResolvedValue('"Ethernet"'))) + ).resolves.toBeNull() + }) + + it('does not inspect routes outside Windows', async () => { + const runPowerShell = vi.fn() + await expect( + getWindowsDefaultRouteInterfaceNames(environment(runPowerShell, 'linux')) + ).resolves.toEqual(new Set()) + expect(runPowerShell).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/windows-default-route-interfaces.ts b/src/main/runtime/windows-default-route-interfaces.ts new file mode 100644 index 00000000000..840fd8abb0f --- /dev/null +++ b/src/main/runtime/windows-default-route-interfaces.ts @@ -0,0 +1,72 @@ +import { execFile } from 'node:child_process' +import { win32 } from 'node:path' + +const POWERSHELL_TIMEOUT_MS = 3_000 + +type PowerShellRunner = (script: string, timeoutMs: number) => Promise + +export type WindowsDefaultRouteEnvironment = { + platform: NodeJS.Platform + systemRoot?: string + runPowerShell?: PowerShellRunner +} + +export async function getWindowsDefaultRouteInterfaceNames( + environment: WindowsDefaultRouteEnvironment = { + platform: process.platform, + systemRoot: process.env.SystemRoot + } +): Promise | null> { + if (environment.platform !== 'win32') { + return new Set() + } + + try { + const stdout = await ( + environment.runPowerShell ?? createPowerShellRunner(environment.systemRoot) + )(buildDefaultRouteScript(), POWERSHELL_TIMEOUT_MS) + const parsed = JSON.parse(stdout.trim()) as unknown + if (!Array.isArray(parsed) || !parsed.every((name) => typeof name === 'string')) { + return null + } + return new Set(parsed) + } catch { + return null + } +} + +function buildDefaultRouteScript(): string { + return `$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' +$names = @(Get-NetRoute -ErrorAction Stop | Where-Object { $_.State -eq 'Alive' -and ($_.DestinationPrefix -eq '0.0.0.0/0' -or $_.DestinationPrefix -eq '::/0') } | Select-Object -ExpandProperty InterfaceAlias -Unique) +ConvertTo-Json -InputObject $names -Compress` +} + +function createPowerShellRunner(systemRoot = 'C:\\Windows'): PowerShellRunner { + const powershellPath = win32.join( + systemRoot, + 'System32', + 'WindowsPowerShell', + 'v1.0', + 'powershell.exe' + ) + return (script, timeoutMs) => + new Promise((resolve, reject) => { + execFile( + powershellPath, + ['-NoProfile', '-NonInteractive', '-EncodedCommand', encodePowerShell(script)], + { encoding: 'utf8', timeout: timeoutMs, windowsHide: true }, + (error, stdout) => { + if (error) { + reject(error) + } else { + resolve(stdout) + } + } + ) + }) +} + +function encodePowerShell(script: string): string { + return Buffer.from(script, 'utf16le').toString('base64') +} diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index b1d9feb03c7..f7c8db7a70f 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -3660,7 +3660,7 @@ export type PreloadApi = { } mobile: { listNetworkInterfaces: () => Promise<{ - interfaces: { name: string; address: string }[] + interfaces: { name: string; address: string; hasDefaultRoute?: boolean }[] }> getPairingQR: (args?: { address?: string diff --git a/src/preload/index.ts b/src/preload/index.ts index 34e195f3848..4c81d8520a4 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -4671,7 +4671,7 @@ const api = { mobile: { listNetworkInterfaces: (): Promise<{ - interfaces: { name: string; address: string }[] + interfaces: { name: string; address: string; hasDefaultRoute?: boolean }[] }> => ipcRenderer.invoke('mobile:listNetworkInterfaces'), getPairingQR: (args?: { diff --git a/src/renderer/src/components/mobile/use-mobile-pairing-address-preference.test.tsx b/src/renderer/src/components/mobile/use-mobile-pairing-address-preference.test.tsx index 9f297fda1cf..c3dfe6d2734 100644 --- a/src/renderer/src/components/mobile/use-mobile-pairing-address-preference.test.tsx +++ b/src/renderer/src/components/mobile/use-mobile-pairing-address-preference.test.tsx @@ -26,8 +26,14 @@ import { useMobilePairingAddressPreference } from './use-mobile-pairing-address- const LAN: MobileNetworkInterface = { name: 'en0', address: '192.168.1.24' } const OTHER: MobileNetworkInterface = { name: 'en1', address: '10.0.0.5' } +const EXTERNAL_SWITCH: MobileNetworkInterface = { + name: 'vEthernet (Lab)', + address: '192.168.1.30', + hasDefaultRoute: true +} +const BRIDGE: MobileNetworkInterface = { name: 'docker0', address: '172.17.0.1' } -function renderPreference() { +function renderPreference(networkInterfaces: readonly MobileNetworkInterface[] = []) { mocks.holder.state = { settings: {}, updateSettings: vi.fn().mockResolvedValue(undefined) @@ -35,7 +41,7 @@ function renderPreference() { const onSelectionInvalidated = vi.fn() const { result } = renderHook(() => useMobilePairingAddressPreference({ - networkInterfaces: [], + networkInterfaces, onSelectionInvalidated }) ) @@ -81,4 +87,35 @@ describe('useMobilePairingAddressPreference', () => { source: 'refresh' }) }) + + it('invalidates an automatic vEthernet selection when route evidence becomes ambiguous', () => { + const { result, onSelectionInvalidated } = renderPreference() + + act(() => result.current.selectAddressAfterRefresh([EXTERNAL_SWITCH])) + act(() => + result.current.selectAddressAfterRefresh([{ ...EXTERNAL_SWITCH, hasDefaultRoute: undefined }]) + ) + + expect(result.current.selectedAddress).toBeUndefined() + expect(onSelectionInvalidated).toHaveBeenCalledExactlyOnceWith({ + address: undefined, + source: 'refresh' + }) + }) + + it('keeps an explicitly selected bridge across refreshes', () => { + const { result, onSelectionInvalidated } = renderPreference([LAN, BRIDGE]) + + act(() => result.current.selectAddressAfterRefresh([LAN, BRIDGE])) + act(() => result.current.selectAddress(BRIDGE.address)) + act(() => result.current.selectAddressAfterRefresh([LAN, BRIDGE])) + act(() => result.current.selectAddressAfterRefresh([])) + + expect(result.current.selectedAddress).toBe(BRIDGE.address) + expect(result.current.selectedAddressIsCustom).toBe(false) + expect(onSelectionInvalidated).toHaveBeenCalledExactlyOnceWith({ + address: BRIDGE.address, + source: 'user' + }) + }) }) diff --git a/src/renderer/src/components/mobile/use-mobile-pairing-address-preference.ts b/src/renderer/src/components/mobile/use-mobile-pairing-address-preference.ts index da54e85ad4e..b8d972a9e9d 100644 --- a/src/renderer/src/components/mobile/use-mobile-pairing-address-preference.ts +++ b/src/renderer/src/components/mobile/use-mobile-pairing-address-preference.ts @@ -45,6 +45,7 @@ export function useMobilePairingAddressPreference(args: { const [customAddresses, setCustomAddresses] = useState(savedCustomAddresses) const selectedAddressRef = useRef(selectedAddress) const selectedAddressIsManualRef = useRef(savedCustomAddress !== undefined) + const selectedAddressWasExplicitlySelectedRef = useRef(savedCustomAddress !== undefined) const customAddressesRef = useRef(savedCustomAddresses) const observedCustomAddressRef = useRef(savedCustomAddress) const pendingCustomAddressWritesRef = useRef<(string | undefined)[]>([]) @@ -54,7 +55,8 @@ export function useMobilePairingAddressPreference(args: { const nextAddress = selectRefreshedNetworkAddress( selectedAddressRef.current, interfaces, - selectedAddressIsManualRef.current + selectedAddressIsManualRef.current, + selectedAddressWasExplicitlySelectedRef.current ) if (nextAddress === selectedAddressRef.current) { return @@ -64,6 +66,7 @@ export function useMobilePairingAddressPreference(args: { const hadSelection = selectedAddressRef.current !== undefined selectedAddressRef.current = nextAddress selectedAddressIsManualRef.current = false + selectedAddressWasExplicitlySelectedRef.current = false setSelectedAddress(nextAddress) setSelectedAddressIsCustom(false) if (hadSelection) { @@ -76,11 +79,16 @@ export function useMobilePairingAddressPreference(args: { const commitAddress = useCallback( (address: string, isManual: boolean): void => { const addressChanged = selectedAddressRef.current !== address - if (!addressChanged && selectedAddressIsManualRef.current === isManual) { + if ( + !addressChanged && + selectedAddressIsManualRef.current === isManual && + selectedAddressWasExplicitlySelectedRef.current + ) { return } selectedAddressRef.current = address selectedAddressIsManualRef.current = isManual + selectedAddressWasExplicitlySelectedRef.current = true setSelectedAddress(address) setSelectedAddressIsCustom(isManual) const customAddress = isManual ? address : undefined @@ -144,6 +152,7 @@ export function useMobilePairingAddressPreference(args: { const addressChanged = selectedAddressRef.current !== nextAddress selectedAddressRef.current = nextAddress selectedAddressIsManualRef.current = false + selectedAddressWasExplicitlySelectedRef.current = false setSelectedAddress(nextAddress) setSelectedAddressIsCustom(false) pendingCustomAddressWritesRef.current.push(undefined) @@ -175,6 +184,7 @@ export function useMobilePairingAddressPreference(args: { const addressChanged = selectedAddressRef.current !== nextAddress selectedAddressRef.current = nextAddress selectedAddressIsManualRef.current = savedCustomAddress !== undefined + selectedAddressWasExplicitlySelectedRef.current = savedCustomAddress !== undefined setSelectedAddress(nextAddress) setSelectedAddressIsCustom(savedCustomAddress !== undefined) if (addressChanged) { diff --git a/src/renderer/src/components/settings/mobile-network-interface-selection.test.ts b/src/renderer/src/components/settings/mobile-network-interface-selection.test.ts index 7fa81a72b37..5ad5fb39f6a 100644 --- a/src/renderer/src/components/settings/mobile-network-interface-selection.test.ts +++ b/src/renderer/src/components/settings/mobile-network-interface-selection.test.ts @@ -7,6 +7,21 @@ import { const LAN: MobileNetworkInterface = { name: 'en0', address: '192.168.1.24' } const TAILNET: MobileNetworkInterface = { name: 'tailscale0', address: '100.64.1.20' } const BRIDGE: MobileNetworkInterface = { name: 'docker0', address: '172.17.0.1' } +const DEFAULT_SWITCH: MobileNetworkInterface = { + name: 'vEthernet (Default Switch)', + address: '172.28.80.1', + hasDefaultRoute: true +} +const WSL_SWITCH: MobileNetworkInterface = { + name: 'vEthernet (WSL (Hyper-V firewall))', + address: '172.20.96.1', + hasDefaultRoute: true +} +const EXTERNAL_SWITCH: MobileNetworkInterface = { + name: 'vEthernet (Lab)', + address: '192.168.1.30', + hasDefaultRoute: true +} describe('selectRefreshedNetworkAddress', () => { // Why: regression for the manual-address branch the PR adds — a @@ -40,10 +55,43 @@ describe('selectRefreshedNetworkAddress', () => { expect(selectRefreshedNetworkAddress(LAN.address, [])).toBeUndefined() }) + it.each([BRIDGE, DEFAULT_SWITCH])( + 'keeps the explicitly selected $name adapter when refresh is empty', + (networkInterface) => { + expect(selectRefreshedNetworkAddress(networkInterface.address, [], false, true)).toBe( + networkInterface.address + ) + } + ) + it('skips a container bridge in favor of a reachable address', () => { expect(selectRefreshedNetworkAddress(undefined, [BRIDGE, LAN])).toBe(LAN.address) }) + it('skips route-positive Default Switch and WSL addresses in favor of LAN', () => { + expect(selectRefreshedNetworkAddress(undefined, [DEFAULT_SWITCH, WSL_SWITCH, LAN])).toBe( + LAN.address + ) + }) + + it('reclassifies a stale auto-selected Default Switch address', () => { + expect(selectRefreshedNetworkAddress(DEFAULT_SWITCH.address, [DEFAULT_SWITCH, LAN])).toBe( + LAN.address + ) + }) + + it('reclassifies a stale auto-selected WSL address', () => { + expect(selectRefreshedNetworkAddress(WSL_SWITCH.address, [WSL_SWITCH, LAN])).toBe(LAN.address) + }) + + it('drops an auto-selected vEthernet address when route evidence becomes ambiguous', () => { + expect( + selectRefreshedNetworkAddress(EXTERNAL_SWITCH.address, [ + { ...EXTERNAL_SWITCH, hasDefaultRoute: undefined } + ]) + ).toBeUndefined() + }) + // Why: matches main's default — advertising nothing beats advertising docker0, and Relay // pairs without a direct address. A divergence here would show an address the QR never carried. it('selects no address when every interface is a container bridge', () => { @@ -51,6 +99,22 @@ describe('selectRefreshedNetworkAddress', () => { }) it('keeps a bridge the user picked explicitly', () => { - expect(selectRefreshedNetworkAddress(BRIDGE.address, [BRIDGE, LAN])).toBe(BRIDGE.address) + expect(selectRefreshedNetworkAddress(BRIDGE.address, [BRIDGE, LAN], false, true)).toBe( + BRIDGE.address + ) }) + + it.each([DEFAULT_SWITCH, WSL_SWITCH])( + 'keeps the explicitly selected known host-local adapter $name', + (networkInterface) => { + expect( + selectRefreshedNetworkAddress( + networkInterface.address, + [networkInterface, LAN], + false, + true + ) + ).toBe(networkInterface.address) + } + ) }) diff --git a/src/renderer/src/components/settings/mobile-network-interface-selection.ts b/src/renderer/src/components/settings/mobile-network-interface-selection.ts index 4b9e4782740..4d1d7c61790 100644 --- a/src/renderer/src/components/settings/mobile-network-interface-selection.ts +++ b/src/renderer/src/components/settings/mobile-network-interface-selection.ts @@ -1,8 +1,12 @@ -import { selectAutoAdvertisedPairingAddress } from '../../../../shared/pairing-address-auto-selection' +import { + isVirtualBridgeInterface, + selectAutoAdvertisedPairingAddress +} from '../../../../shared/pairing-address-auto-selection' export type MobileNetworkInterface = { name: string address: string + hasDefaultRoute?: boolean } export function selectRefreshedNetworkAddress( @@ -11,17 +15,23 @@ export function selectRefreshedNetworkAddress( // Why: callers that explicitly know the user picked a manual address // (not an OS-enumerated one) pass this so the refresh path keeps their // selection instead of snapping back to a tailnet/LAN fallback. - currentAddressIsManual: boolean = false + currentAddressIsManual: boolean = false, + currentAddressWasExplicitlySelected: boolean = false ): string | undefined { - // Why: an empty refresh result usually means discovery is transiently - // unavailable, not that the user wants to drop their selection. Keep the - // manual address so a recovering discovery doesn't clobber it. + // Why: transient discovery failure must not clobber a deliberate user choice. if (interfaces.length === 0) { - return currentAddressIsManual ? currentAddress : undefined + return currentAddressIsManual || currentAddressWasExplicitlySelected + ? currentAddress + : undefined } + if (currentAddressIsManual) { + return currentAddress + } + const currentInterface = interfaces.find((iface) => iface.address === currentAddress) if ( - currentAddress && - (currentAddressIsManual || interfaces.some((iface) => iface.address === currentAddress)) + currentInterface && + (currentAddressWasExplicitlySelected || + !isVirtualBridgeInterface(currentInterface.name, currentInterface.hasDefaultRoute)) ) { return currentAddress } diff --git a/src/shared/pairing-address-auto-selection.test.ts b/src/shared/pairing-address-auto-selection.test.ts new file mode 100644 index 00000000000..79dc763e6e6 --- /dev/null +++ b/src/shared/pairing-address-auto-selection.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest' +import { + isVirtualBridgeInterface, + selectAutoAdvertisedPairingAddress, + type PairingNetworkInterface +} from './pairing-address-auto-selection' + +const EXTERNAL_SWITCH = { + name: 'vEthernet (Production LAN)', + address: '192.168.50.24', + hasDefaultRoute: true +} satisfies PairingNetworkInterface & { hasDefaultRoute: boolean } +const DEFAULT_SWITCH: PairingNetworkInterface = { + name: 'vEthernet (Default Switch)', + address: '172.28.80.1', + hasDefaultRoute: true +} +const WSL_SWITCH: PairingNetworkInterface = { + name: 'vEthernet (WSL (Hyper-V firewall))', + address: '172.20.96.1', + hasDefaultRoute: true +} +const PHYSICAL_LAN: PairingNetworkInterface = { + name: 'Ethernet', + address: '192.168.50.25' +} +const HOST_LOCAL_BRIDGE: PairingNetworkInterface = { + name: 'docker0', + address: '172.17.0.1' +} +const AMBIGUOUS_SWITCH: PairingNetworkInterface = { + name: 'vEthernet (Lab)', + address: '10.40.0.1' +} + +describe('selectAutoAdvertisedPairingAddress', () => { + it('allows a reachable Hyper-V external-switch management address', () => { + expect(selectAutoAdvertisedPairingAddress([EXTERNAL_SWITCH])).toBe(EXTERNAL_SWITCH.address) + }) + + it('selects a physical address while filtering Default Switch, WSL, and host-local bridges', () => { + expect( + selectAutoAdvertisedPairingAddress([ + DEFAULT_SWITCH, + WSL_SWITCH, + HOST_LOCAL_BRIDGE, + PHYSICAL_LAN + ]) + ).toBe(PHYSICAL_LAN.address) + }) + + it('filters a vEthernet adapter when route reachability is ambiguous', () => { + expect(selectAutoAdvertisedPairingAddress([AMBIGUOUS_SWITCH])).toBeUndefined() + }) + + it.each([ + 'vEthernet (Default Switch)', + 'vEthernet (default switch)', + 'vEthernet (WSL)', + 'vEthernet (WSL (Hyper-V firewall))' + ])('keeps the known host-local %s label filtered with a default route', (name) => { + expect(isVirtualBridgeInterface(name, true)).toBe(true) + }) + + it.each([ + 'vEthernet (Default Switchboard)', + 'vEthernet (WSL-LAN)', + 'vEthernet (WSL LAN)', + 'vEthernet (WSL External)' + ])('does not treat the route-backed near-match %s as a known host-local label', (name) => { + expect(isVirtualBridgeInterface(name, true)).toBe(false) + }) + + it.each([ + ['external', EXTERNAL_SWITCH, false], + ['Default Switch', DEFAULT_SWITCH, true], + ['WSL', WSL_SWITCH, true], + ['physical', PHYSICAL_LAN, false], + ['host-local', HOST_LOCAL_BRIDGE, true], + ['ambiguous', AMBIGUOUS_SWITCH, true] + ] as const)('classifies the %s fixture', (_label, fixture, expected) => { + expect(isVirtualBridgeInterface(fixture.name, fixture.hasDefaultRoute)).toBe(expected) + }) +}) diff --git a/src/shared/pairing-address-auto-selection.ts b/src/shared/pairing-address-auto-selection.ts index b263b670466..a179a66b18f 100644 --- a/src/shared/pairing-address-auto-selection.ts +++ b/src/shared/pairing-address-auto-selection.ts @@ -3,17 +3,24 @@ import { isTailnetIPv4Address } from './tailnet-address' export type PairingNetworkInterface = { name: string address: string + hasDefaultRoute?: boolean } -// Why: container/VM bridges are host-local — a phone can never reach docker0 or -// vmnet8 — but they enumerate as ordinary non-internal IPv4, so advertising one -// makes the direct path silently lose the pairing race and every session relay. -// Keyed on interface name, not subnet: Docker's 172.16/12 pool overlaps real -// corporate LANs, so an address test would demote genuine addresses. +// Why: known bridge labels are host-local, while vEthernet needs positive route evidence because +// External Switch management adapters are reachable; subnets overlap real corporate LANs. const VIRTUAL_BRIDGE_INTERFACE_PATTERN = - /^(?:docker|br-|virbr|vmnet|vboxnet|veth|lxcbr|cni|flannel|cali|bridge)|^vEthernet |VMware Network Adapter|VirtualBox Host-Only/i + /^(?:docker|br-|virbr|vmnet|vboxnet|veth|lxcbr|cni|flannel|cali|bridge)|VMware Network Adapter|VirtualBox Host-Only/i +const HYPER_V_INTERFACE_PATTERN = /^vEthernet /i +const HOST_LOCAL_HYPER_V_INTERFACE_PATTERN = + /^vEthernet \((?:Default Switch|WSL(?: \(Hyper-V firewall\))?)\)$/i -export function isVirtualBridgeInterface(name: string): boolean { +export function isVirtualBridgeInterface(name: string, hasDefaultRoute?: boolean): boolean { + if (HOST_LOCAL_HYPER_V_INTERFACE_PATTERN.test(name)) { + return true + } + if (HYPER_V_INTERFACE_PATTERN.test(name)) { + return hasDefaultRoute !== true + } return VIRTUAL_BRIDGE_INTERFACE_PATTERN.test(name) } @@ -24,7 +31,9 @@ export function isVirtualBridgeInterface(name: string): boolean { export function selectAutoAdvertisedPairingAddress( interfaces: readonly PairingNetworkInterface[] ): string | undefined { - const advertisable = interfaces.filter((iface) => !isVirtualBridgeInterface(iface.name)) + const advertisable = interfaces.filter( + (iface) => !isVirtualBridgeInterface(iface.name, iface.hasDefaultRoute) + ) return ( advertisable.find((iface) => isTailnetIPv4Address(iface.address))?.address ?? advertisable[0]?.address